From dd6d8724ac86ab77ccad94c7c142f5dbcf2dda81 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 17 Sep 2026 16:41:28 +0800 Subject: [PATCH 01/21] feat(perps-controller): rework subscription fee waiver for ADR 0064 cloid marking ADR 0064 has moved past the revision TAT-3618 was built against and rejects the dedicated approved-builder approach it shipped, citing per-user approval overhead and no order context. Rework the perps-controller side to match. Resolve the subscription source as a blended rate rather than a flat zero: 0 bips when the remaining allowance covers the order notional, otherwise MaxFee * (1 - remaining / orderNotional). That rate now competes in the existing lowest-wins comparison instead of short-circuiting it, so a partial blend can lose to a deeper VIP or season discount. The formula lives in one pure helper that preview and submit both call, and calculateFees threads the order notional through it, so a quoted fee and a charged fee cannot drift. Move subscription attribution from the builder address to the order's client order ID. Every source now pays through the standard builder at the resolved fee, which is also what lets a partial waiver charge a real blended fee. One provider helper stamps the program marker and a fee_reduction_applied flag when subscription wins, and every submission path routes through it: placement, Scale ladder, attached and standalone TP/SL, position TP/SL update, batch close, modify/replace, and chase. Any other source leaves the id untouched. The flag byte sits after the leading marker rather than replacing it, so a Scale rung keeps its group marker and rung index and stays recoverable. The Scale identity generator now reserves that byte; without it, random entropy would set the flag on roughly half of all unmarked ladders. Add SubscriptionController allowed actions for benefits hydration and CAIP-10 trading-address registration at preview time, re-sent after an account switch, falling back to the injected dependency when a client registers neither. Add the perpsSubscriptionFeeWaiverEnabled remote flag, which kills only the subscription source and fails open. Deprecate the dedicated subscription builder rather than deleting it: the acceptance criterion conditions removal on shadow-mode verification, which has not happened, so the approval path is made unreachable from order construction and kept for a cheap rollback. The cloid program marker is a placeholder; the registry value is an open TODO in the ADR and is owned by the cloid schema owners. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 15 + .../perps-controller/src/PerpsController.ts | 54 ++- .../src/constants/perpsConfig.ts | 54 +++ .../src/providers/AggregatedPerpsProvider.ts | 7 + .../src/providers/HyperLiquidProvider.ts | 154 ++++++--- .../src/services/MarketDataService.ts | 20 +- .../src/services/RewardsIntegrationService.ts | 217 +++++++++++- .../src/services/ServiceContext.ts | 10 + packages/perps-controller/src/types/index.ts | 24 +- .../perps-controller/src/types/messenger.ts | 31 ++ .../src/utils/hyperLiquidAdapter.ts | 4 +- .../src/utils/subscriptionFeeWaiver.ts | 322 ++++++++++++++++++ .../src/PerpsController.operations.test.ts | 192 ++++++++++- .../PerpsController.providers-cache.test.ts | 24 +- .../HyperLiquidProvider.builder-fees.test.ts | 257 +++++++++----- ...yperLiquidProvider.strategy-orders.test.ts | 60 ++++ .../HyperLiquidProvider.trading.test.ts | 136 ++++++++ .../RewardsIntegrationService.test.ts | 301 ++++++++++++++++ .../src/utils/subscriptionFeeWaiver.test.ts | 312 +++++++++++++++++ 19 files changed, 2020 insertions(+), 174 deletions(-) create mode 100644 packages/perps-controller/src/utils/subscriptionFeeWaiver.ts create mode 100644 packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1973eb839f0..ac01a678103 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. +- Add `SubscriptionController:getPerpsBenefits` and `SubscriptionController:registerAddress` to `PerpsControllerAllowedActions`, so benefits hydration and trading-address registration can run over the messenger. Clients that do not register these actions keep using the injected `subscription` dependency. +- Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. + ### Changed +- **BREAKING:** `PerpsController.calculateFees` now quotes the subscription fee waiver as a blended rate derived from the order notional, so `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from previous releases when a subscription waiver applies. + - Pass the order notional (USD) as `FeeCalculationParams.amount` to receive the rate the order will actually be charged. Omitting it quotes the full-waiver rate, matching the previous behavior. +- Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. +- Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins. Any other fee source leaves the client order ID untouched. Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. +- Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) - Bump `uuid` from `^9.0.1` to `^11.1.1` ([#10243](https://github.com/MetaMask/core/pull/10243)) +### Deprecated + +- Deprecate `PerpsController.approveSubscriptionBuilderFee`, `PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated subscription builder address configuration. Subscription attribution now rides on the order's client order ID rather than a separate approved builder, so the controller method is a no-op that always resolves `false`. The provider-side approval machinery is retained but unreachable from order construction. + ### Fixed - Normalize Lighter order timestamps from seconds to milliseconds for client date displays. ([#10187](https://github.com/MetaMask/core/pull/10187)) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 384a3aafe1a..f6b7c570c36 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1277,6 +1277,22 @@ export class PerpsController extends BaseController< this.refreshEligibilityOnFeatureFlagChange.bind(this), ); + // Also subscribed for the controller lifetime: the subscription profile + // must learn the new trading address after every account switch, and the + // preload-scoped account handler is torn down on disconnect, so it cannot + // carry this. + const forgetRegisteredTradingAddresses = (): void => { + this.#rewardsIntegrationService.resetRegisteredTradingAddresses(); + }; + this.messenger.subscribe( + 'AccountsController:selectedAccountChange', + forgetRegisteredTradingAddresses, + ); + this.messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + forgetRegisteredTradingAddresses, + ); + this.providers = new Map(); // Migrate old persisted data without accountAddress @@ -5779,27 +5795,51 @@ export class PerpsController extends BaseController< // cache read and can therefore never start a benefits request while an // order is being signed. await this.#rewardsIntegrationService.refreshSubscriptionBenefits(); + + // ADR 0064: preview is also where the trading address is announced, so a + // fill decoded off the HL fan-out can be attributed back to a profile. + // Fire-and-forget — attribution plumbing must not delay or fail a quote. + const selectedAccount = getSelectedEvmAccountFromMessenger(this.messenger); + if (selectedAccount) { + this.#rewardsIntegrationService + .registerTradingAddress(selectedAccount.address) + .catch(() => { + /* never blocks a fee preview */ + }); + } + const waiverStatus = this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); + // The preview quotes the same blended rate the submit path charges, which + // is only possible once the order notional reaches the resolver. `amount` + // is the order notional in USD for the quote being previewed. + const orderNotionalUsd = params.amount + ? Number.parseFloat(params.amount) + : undefined; + const feeResolution = + await this.#rewardsIntegrationService.resolveFee(orderNotionalUsd); const context = this.#createServiceContext('calculateFees', { subscriptionFeeWaiver: waiverStatus.reason === 'no-source' ? undefined : waiverStatus, + feeResolution, }); return this.#marketDataService.calculateFees({ provider, params, context }); } /** * Approve the dedicated subscription builder outside order submission. - * Until this succeeds, subscription waivers fall back to the ordinary - * builder at the standard fee. * - * @returns Whether the subscription builder is approved. + * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid + * marking on the standard builder, so there is nothing left to approve. Kept + * as a no-op returning `false` so clients still calling it keep building + * while they migrate; remove it once cloid marking is verified in shadow mode. + * @returns Always `false`. */ async approveSubscriptionBuilderFee(): Promise { - const provider = this.getActiveProvider(); - return provider.approveSubscriptionBuilderFee - ? provider.approveSubscriptionBuilderFee() - : false; + this.#debugLog( + 'PerpsController: approveSubscriptionBuilderFee is a no-op; subscription attribution now rides on the order cloid', + ); + return false; } /** diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 35768289a7e..4c85ec83903 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -416,6 +416,60 @@ export const SUBSCRIPTION_BENEFITS_CACHE = { MaxStaleMs: 10 * 60 * 1000, // 10 minutes – ceiling for granting the waiver } as const; +/** + * Client order ID marking for the ADR 0064 subscription fee waiver. + * + * A HyperLiquid cloid is 16 bytes. When the subscription source wins the fee + * comparison, the order's cloid is stamped so the fill can be attributed to the + * subscription program off the existing HL fill fan-out, with no dedicated + * builder address and no per-user approval: + * + * ``` + * 0x + * ``` + * + * The flag byte follows the leading marker rather than replacing it, so the + * marking composes with the cloid the Scale ladder already builds: a Scale rung + * keeps its own `4d4d5343` marker and its rung index, and only the flag byte is + * claimed, leaving group recovery and cancel-by-cloid intact. + * + * `ProgramId` is a placeholder. The registry value is an open `[TODO]` in ADR + * 0064 and belongs to the cloid schema owners, so it is deliberately isolated + * in this one constant: adopting the real value is a one-line change and every + * marking/decoding path already reads it from here. + */ +export const SUBSCRIPTION_CLOID_CONFIG = { + /** + * Reserved program marker, 4 bytes as lowercase hex without the `0x`. + * PLACEHOLDER — pending the cloid registry value from ADR 0064. + */ + ProgramId: '4d4d5342', + + /** Hex characters in the leading program marker (4 bytes). */ + ProgramIdHexLength: 8, + + /** Hex characters of trailing entropy in a marked cloid (11 bytes). */ + EntropyHexLength: 22, +} as const; + +/** + * Flag bits carried in the flag byte of a subscription-marked cloid. + */ +export const SUBSCRIPTION_CLOID_FLAGS = { + /** Bit 0 — a subscription fee reduction was applied to this order. */ + FeeReductionApplied: 0x01, +} as const; + +/** + * Remote feature flag that gates the subscription fee-waiver source. + * + * ADR 0064 Milestone 8 requires the subscription source to be killable on its + * own, without touching VIP, season, or the default builder fee. Absent or + * malformed, the flag reads as enabled so an unreachable flag service cannot + * silently drop a benefit the user pays for. + */ +export const SUBSCRIPTION_FEE_WAIVER_FLAG = 'perpsSubscriptionFeeWaiverEnabled'; + /** * Terminal API configuration. * The full endpoint URL is injected at runtime via diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 576a041ea5e..e0d7bd9725a 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -996,6 +996,13 @@ export class AggregatedPerpsProvider implements PerpsProvider { }); } + /** + * Approve the dedicated subscription builder on the HyperLiquid provider. + * + * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid + * marking on the standard builder; nothing on the order path reads this. + * @returns Whether the builder is approved. + */ async approveSubscriptionBuilderFee(): Promise { const provider = this.#providers.get('hyperliquid') ?? this.#getDefaultProvider(); diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index cac840db42c..6544e6890e6 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -186,6 +186,7 @@ import { formatHyperLiquidSize, HYPERLIQUID_SCALE_CLOID_MARKER, parseAssetName, + readScaleGroupId, } from '../utils/hyperLiquidAdapter.js'; import { previewHyperLiquidIsolatedPositionModify, @@ -237,6 +238,7 @@ import { queryStandaloneOpenOrders, } from '../utils/standaloneInfoClient.js'; import { parseBoundedNonNegativeDecimal } from '../utils/stringParseUtils.js'; +import { markSubscriptionCloid } from '../utils/subscriptionFeeWaiver.js'; // getStreamManagerInstance removed: use this.#deps.streamManager instead const HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE = { @@ -925,13 +927,18 @@ type ScaleOrderGroup = { * rung. HyperLiquid requires every CLOID to be unique, so the last byte holds * the rung index while the preceding 15 bytes identify the shared group. * + * The byte right after the Scale marker is reserved for the subscription flag + * byte and is always zeroed here. Random entropy in that position would set the + * `fee_reduction_applied` bit roughly half the time, so an unmarked ladder would + * decode downstream as a waived one. + * * @param count - Number of ladder rungs. * @returns The public group handle and venue client order IDs. */ const createScaleOrderIdentity = (count: number): ScaleOrderIdentity => { - const groupKey = `${HYPERLIQUID_SCALE_CLOID_MARKER}${uuidv4() + const groupKey = `${HYPERLIQUID_SCALE_CLOID_MARKER}00${uuidv4() .replace(/-/gu, '') - .slice(0, 22)}`; + .slice(0, 20)}`; const clientOrderIds = Array.from({ length: count }, (_, index) => { const clientOrderId: Hex = `0x${groupKey}${index .toString(16) @@ -4353,6 +4360,13 @@ export class HyperLiquidProvider implements PerpsProvider { * Failure is non-blocking: order construction will use the ordinary builder * at the standard fee until a later approval succeeds. * + * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid + * marking on the standard builder, so nothing reads this approval any more — + * {@link #getBuilderOrderContext} never selects the subscription builder + * address. The approval machinery is kept intact, unreachable, so the + * previous design can be restored cheaply if cloid marking does not hold up + * in shadow mode; remove it, the builder-address config, and + * {@link #getSubscriptionBuilderAddress} once it does. * @returns Whether the builder is approved for the current account. */ async approveSubscriptionBuilderFee(): Promise { @@ -5324,7 +5338,7 @@ export class HyperLiquidProvider implements PerpsProvider { try { const result = await exchangeClient.order({ - orders, + orders: this.#applySubscriptionCloid(orders), grouping, ...(builder && { builder }), }); @@ -6234,17 +6248,32 @@ export class HyperLiquidProvider implements PerpsProvider { } const { prices, sizes } = ladder; const count = prices.length; - const { groupId, clientOrderIds } = createScaleOrderIdentity(count); - - const orders: SDKOrderParams[] = prices.map((price, index) => ({ - a: assetId, - b: params.isBuy, - p: price, - s: sizes[index], - r: params.reduceOnly ?? false, - t: { limit: { tif: 'Gtc' as const } }, - c: clientOrderIds[index], - })); + const { clientOrderIds: ladderClientOrderIds } = + createScaleOrderIdentity(count); + + // Marked here rather than at submission so the ids recorded for + // cancel-by-cloid are exactly the ids the venue received. Marking keeps the + // Scale group marker and the rung index, so the group stays recoverable. + const orders: SDKOrderParams[] = this.#applySubscriptionCloid( + prices.map((price, index) => ({ + a: assetId, + b: params.isBuy, + p: price, + s: sizes[index], + r: params.reduceOnly ?? false, + t: { limit: { tif: 'Gtc' as const } }, + c: ladderClientOrderIds[index], + })), + ); + const clientOrderIds: Hex[] = orders.map((order, index) => + order.c === undefined ? ladderClientOrderIds[index] : (order.c as Hex), + ); + // Derived from the submitted ids, not from the pre-marking identity: the + // group is recovered from open orders by reading their cloids, so tracking + // it under any other key would register the same ladder twice. + const groupId = + readScaleGroupId(clientOrderIds[0]) ?? + `scale:${clientOrderIds[0].slice(2, -2)}`; this.#deps.debugLogger.log('Submitting scale ladder', { symbol: params.symbol, @@ -6771,7 +6800,7 @@ export class HyperLiquidProvider implements PerpsProvider { exchangeClient: ExchangeClient; }): Promise { const result = await params.exchangeClient.order({ - orders: [ + orders: this.#applySubscriptionCloid([ { a: params.assetId, b: params.isBuy, @@ -6782,7 +6811,7 @@ export class HyperLiquidProvider implements PerpsProvider { // the chase on its first tick at a worse price than resting does. t: { limit: { tif: 'Alo' as const } }, }, - ], + ]), grouping: 'na', ...(params.builder && { builder: params.builder }), }); @@ -8544,9 +8573,12 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Resolve the builder payload for the current operation. * - * Subscription waivers use their dedicated builder only after approval is - * cached for this provider/account session. Until then, the ordinary builder - * and standard fee keep the trade attributable and non-blocking. + * ADR 0064 removed the dedicated subscription builder: every source — the + * subscription waiver included — now pays through the standard builder at the + * resolved fee, and the subscription attribution rides on the order's cloid + * instead (see {@link #applySubscriptionCloid}). That drops the per-user + * builder approval the ADR rejected, and lets a partial waiver charge a real + * blended fee, which a dedicated 0-bips builder could not express. * * @param setupContext - Account, network, and builder approved for the order. * @returns HyperLiquid builder address and fee payload. @@ -8554,36 +8586,49 @@ export class HyperLiquidProvider implements PerpsProvider { async #getBuilderOrderContext( setupContext: BuilderFeeSetupContext, ): Promise<{ b: string; f: number }> { - const { - network, - userAddress, - builderAddress: defaultBuilder, - } = setupContext; - const isTestnet = network === 'testnet'; + return { + b: setupContext.builderAddress, + f: this.#getDiscountedBuilderFee(), + }; + } - if (this.#userFeeResolution?.source === 'subscription') { - const subscriptionBuilder = - this.#getSubscriptionBuilderAddress(isTestnet); - if ( - subscriptionBuilder && - this.#approvedBuilderAddresses.has( - this.#getApprovedBuilderKey( - network, - userAddress, - subscriptionBuilder, - ), - ) - ) { - return { b: subscriptionBuilder, f: 0 }; - } + /** + * Whether the subscription source won the fee for the operation in flight. + * + * @returns True when the resolved source is `subscription`. + */ + #isSubscriptionFeeSource(): boolean { + return this.#userFeeResolution?.source === 'subscription'; + } - return { - b: defaultBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }; + /** + * Mark an order's cloid for the subscription program when it won the fee. + * + * This is the single place a cloid becomes subscription-attributed. Every + * placement path — primary submit, scale ladder, TP/SL (attached and + * standalone), batch close, modify/replace, and chase — routes its orders + * through here, so no path can silently ship an unmarked order while the + * waiver is being charged, and no other fee source can produce a marked one. + * + * Orders that already carry a cloid keep their trailing entropy, so the Scale + * ladder's per-rung index and its cancel-by-cloid recovery survive marking. + * + * @param orders - The SDK order payloads about to be submitted. + * @returns The same payloads, with cloids marked when subscription won. + */ + #applySubscriptionCloid(orders: SDKOrderParams[]): SDKOrderParams[] { + if (!this.#isSubscriptionFeeSource()) { + // Any other source leaves the id exactly as the caller built it. + return orders; } - return { b: defaultBuilder, f: this.#getDiscountedBuilderFee() }; + return orders.map((order) => ({ + ...order, + c: markSubscriptionCloid({ + clientOrderId: order.c ?? undefined, + entropy: uuidv4().replace(/-/gu, ''), + }), + })); } /** @@ -8875,12 +8920,13 @@ export class HyperLiquidProvider implements PerpsProvider { // Submit modification via SDK const exchangeClient = this.#clientService.getExchangeClient(); + const [markedNewOrder] = this.#applySubscriptionCloid([newOrder]); const result = await exchangeClient.modify({ oid: typeof params.orderId === 'string' ? (params.orderId as Hex) : params.orderId, - order: newOrder, + order: markedNewOrder, }); if (result.status !== 'ok') { @@ -9434,7 +9480,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Single batch API call const result = await exchangeClient.order({ - orders, + orders: this.#applySubscriptionCloid(orders), grouping: 'na', ...(builder && { builder }), }); @@ -10052,7 +10098,9 @@ export class HyperLiquidProvider implements PerpsProvider { try { const result = await exchangeClient.order({ - orders: entries.map((entry) => entry.order), + orders: this.#applySubscriptionCloid( + entries.map((entry) => entry.order), + ), grouping: protection.grouping, ...(entries.some((entry) => entry.chargesMetamaskBuilderFee) && builderOrderContext && { builder: builderOrderContext }), @@ -10205,7 +10253,7 @@ export class HyperLiquidProvider implements PerpsProvider { let result: Awaited>; try { result = await exchangeClient.order({ - orders, + orders: this.#applySubscriptionCloid(orders), grouping: isPartialTpsl ? 'na' : 'positionTpsl', ...(replacementChargesMetamaskBuilderFee && builderOrderContext && { builder: builderOrderContext }), @@ -14997,6 +15045,14 @@ export class HyperLiquidProvider implements PerpsProvider { return this.#builderAddressMainnet || BUILDER_FEE_CONFIG.MainnetBuilder; } + /** + * The dedicated subscription builder address for this network, if configured. + * + * @deprecated Only {@link approveSubscriptionBuilderFee} still reads this; + * order construction no longer does. See that method for the removal plan. + * @param isTestnet - Whether the provider is in testnet mode. + * @returns The configured address, or undefined. + */ #getSubscriptionBuilderAddress(isTestnet: boolean): string | undefined { return isTestnet ? this.#subscriptionBuilderAddressTestnet diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index d496f409a18..66ddf4fadca 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -41,6 +41,7 @@ import type { CandleData } from '../types/perps-types.js'; import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js'; import { ensureError, isAbortError } from '../utils/errorUtils.js'; import { applyMarketFilters } from '../utils/marketUtils.js'; +import { applyFeeResolution } from '../utils/subscriptionFeeWaiver.js'; import type { ServiceContext } from './ServiceContext.js'; /** @@ -1338,12 +1339,23 @@ export class MarketDataService { try { const fees = await provider.calculateFees(params); + // Re-price the MetaMask component from the unified resolution, which the + // controller computed against this quote's own order notional. The + // provider only knows the discount the last submit pushed into it, so + // without this a partial subscription blend would be quoted at a rate the + // order does not actually pay. + const priced = applyFeeResolution({ + fees, + resolution: context.feeResolution, + amount: params.amount, + }); + // Read-only preview of the same cached benefits snapshot the fee resolver - // reads. The quoted rates are left untouched: surfacing eligibility and - // the remaining notional must not mutate the cap or the cache. + // reads. Surfacing eligibility and the remaining notional must not mutate + // the cap or the cache. return context.subscriptionFeeWaiver - ? { ...fees, subscription: context.subscriptionFeeWaiver } - : fees; + ? { ...priced, subscription: context.subscriptionFeeWaiver } + : priced; } catch (error) { this.#deps.logger.error( ensureError(error, 'MarketDataService.calculateFees'), diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 228e70bbc8c..24c55db47ba 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -5,6 +5,7 @@ import { import { PERPS_CONSTANTS, SUBSCRIPTION_BENEFITS_CACHE, + SUBSCRIPTION_FEE_WAIVER_FLAG, } from '../constants/perpsConfig.js'; import type { PerpsFeeResolution, @@ -17,6 +18,7 @@ import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; import { ensureError } from '../utils/errorUtils.js'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.js'; +import { resolveSubscriptionWaiverRate } from '../utils/subscriptionFeeWaiver.js'; /** * Default MetaMask builder fee, in basis points. @@ -88,6 +90,13 @@ export class RewardsIntegrationService { */ #benefitsEpoch = 0; + /** + * CAIP-10 addresses already registered with the subscription profile this + * session, so preview does not re-send the same registration on every + * keystroke. Cleared on account switch and on cache invalidation. + */ + readonly #registeredTradingAddresses = new Set(); + /** * Create a new RewardsIntegrationService instance * @@ -125,10 +134,13 @@ export class RewardsIntegrationService { * Calculate user fee discount from the unified fee resolver. * Returns discount in basis points (e.g., 6500 = 65% discount) * + * @param orderNotionalUsd - Order notional (USD), when the caller knows it. * @returns The fee discount in basis points, or undefined if no source resolved. */ - async calculateUserFeeDiscount(): Promise { - const resolution = await this.resolveFee(); + async calculateUserFeeDiscount( + orderNotionalUsd?: number, + ): Promise { + const resolution = await this.resolveFee(orderNotionalUsd); return resolution.discountBips; } @@ -139,9 +151,17 @@ export class RewardsIntegrationService { * unresolved cached source simply drops out of the comparison, so the worst * case is the default fee rather than an error or an over-granted waiver. * + * The subscription source contributes an effective rate rather than a flat + * zero (ADR 0064): the allowance may cover only part of the order, and the + * blend that results has to be able to lose to a deeper VIP or season + * discount. Passing the order notional is what makes that blend possible; a + * caller with no notional to quote against (a rate-only preview) gets the + * full-waiver rate, which is the pre-ADR behavior. + * + * @param orderNotionalUsd - Order notional (USD), when the caller knows it. * @returns The winning fee, its source, and the subscription gate outcome. */ - async resolveFee(): Promise { + async resolveFee(orderNotionalUsd?: number): Promise { const rewardsDiscountBips = await this.#calculateRewardsDiscount(); // Pure cache read: subscription benefits must never start a network request // while an order is being prepared for signing. @@ -161,10 +181,24 @@ export class RewardsIntegrationService { } } - // Nothing can undercut a waived fee, so the gate passing always wins. - if (subscription.eligible) { - feeBips = 0; + const waiver = resolveSubscriptionWaiverRate({ + status: subscription, + maxFeeBips: DEFAULT_FEE_BIPS, + orderNotionalUsd, + }); + + let subscriptionWaiverKind: PerpsFeeResolution['subscriptionWaiverKind']; + let subscriptionCoveredNotionalUsd: number | undefined; + + // The waiver competes like any other source. A full waiver still wins on + // `<=`, but a partial blend only wins when it is genuinely cheaper than the + // rewards discount — the ADR's requirement that subscription be able to + // lose. + if (waiver.applies && waiver.feeBips <= feeBips) { + feeBips = waiver.feeBips; source = 'subscription'; + subscriptionWaiverKind = waiver.kind === 'partial' ? 'partial' : 'full'; + subscriptionCoveredNotionalUsd = waiver.coveredNotionalUsd; } const discountBips = @@ -178,11 +212,21 @@ export class RewardsIntegrationService { discountBips, defaultFeeBips: DEFAULT_FEE_BIPS, rewardsDiscountBips, + orderNotionalUsd, subscriptionEligible: subscription.eligible, subscriptionReason: subscription.reason, + subscriptionWaiverKind, + subscriptionCoveredNotionalUsd, }); - return { feeBips, discountBips, source, subscription }; + return { + feeBips, + discountBips, + source, + subscription, + subscriptionWaiverKind, + subscriptionCoveredNotionalUsd, + }; } /** @@ -198,6 +242,14 @@ export class RewardsIntegrationService { return { eligible: false, reason: 'no-source' }; } + // ADR 0064 Milestone 8: the subscription source has to be killable on its + // own. Reported as `no-source` so a disabled flag is indistinguishable from + // an unwired client downstream — both mean "subscription contributes + // nothing", and neither touches rewards or the default fee. + if (!this.#isSubscriptionFeeWaiverEnabled()) { + return { eligible: false, reason: 'no-source' }; + } + const now = Date.now(); const snapshot = this.#benefitsSnapshot; const age = snapshot ? now - snapshot.fetchedAt : Infinity; @@ -214,6 +266,29 @@ export class RewardsIntegrationService { return evaluateFeeWaiverGate(snapshot.benefits); } + /** + * Whether the subscription fee-waiver source is enabled remotely. + * + * Fails open: an absent flag, a malformed value, or an unreachable flag + * controller all read as enabled, because silently dropping a benefit the + * user pays for is worse than serving it one release too long. The kill + * switch is an explicit `false`. + * + * @returns True unless the remote flag explicitly disables the source. + */ + #isSubscriptionFeeWaiverEnabled(): boolean { + try { + const { remoteFeatureFlags } = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const flag = remoteFeatureFlags?.[SUBSCRIPTION_FEE_WAIVER_FLAG]; + return flag !== false; + } catch { + // No flag controller registered, or the read threw: keep the source. + return true; + } + } + /** * Refresh the cached subscription benefits snapshot. * @@ -281,6 +356,9 @@ export class RewardsIntegrationService { // fetching for the new identity. Its `finally` guard compares against the // current handle, so it will not clear whatever replaces it here. this.#benefitsRefresh = undefined; + // The identity behind the registration changed too, so the new one has to + // announce itself rather than inherit the previous profile's registration. + this.#registeredTradingAddresses.clear(); this.#deps.debugLogger.log( 'RewardsIntegrationService: Subscription benefits cache invalidated', @@ -299,7 +377,7 @@ export class RewardsIntegrationService { const epoch = this.#benefitsEpoch; try { - const benefits = await source.getPerpsBenefits(); + const benefits = await this.#getPerpsBenefits(source); if (epoch !== this.#benefitsEpoch) { // Invalidated while this read was in flight: it belongs to a previous @@ -348,6 +426,129 @@ export class RewardsIntegrationService { } } + /** + * Read subscription benefits, preferring the messenger over the DI callback. + * + * ADR 0064 moves hydration onto `SubscriptionController`. Clients that have + * not shipped it yet register no such action, and the messenger throws on an + * unregistered action name — so the injected `subscription` dependency stays + * the fallback rather than a second source of truth. + * + * @param source - The injected subscription benefits source. + * @returns The benefits payload, or null when there is none to report. + */ + async #getPerpsBenefits( + source: NonNullable, + ): Promise { + let pending: Promise | undefined; + try { + // Called without awaiting so the fallback stays synchronous when no + // handler is registered: the DI read must start in the same tick, or a + // caller that inspects the in-flight state sees an idle service. + const result = this.#messenger.call( + 'SubscriptionController:getPerpsBenefits', + ); + // `null` is a real answer ("no subscription"); `undefined` means nothing + // handled the action, which is the fallback case rather than an answer. + if (result !== undefined) { + pending = Promise.resolve(result); + } + } catch { + // Unregistered action or a throwing handler: fall through to the DI source. + } + + if (pending) { + try { + return await pending; + } catch { + // A registered handler that rejects still falls back rather than + // erasing the cached snapshot. + } + } + + return await source.getPerpsBenefits(); + } + + /** + * Register the current HyperLiquid trading address with the subscription + * profile, so a later fill decoded off the HL fan-out can be attributed. + * + * ADR 0064 calls for this at preview time and again whenever the selected + * account changes. Registration is idempotent backend-side and deliberately + * never throws: it is observability plumbing, and a failure here must not + * block a fee preview. + * + * @param address - The EVM trading address to register. + * @returns A promise that resolves once the attempt settles. + */ + async registerTradingAddress(address: string): Promise { + if (!this.#deps.subscription) { + return; + } + + try { + const networkState = this.#messenger.call('NetworkController:getState'); + const chainId = this.#getChainIdForNetwork( + networkState.selectedNetworkClientId, + ); + + if (!chainId) { + return; + } + + const caipAccountId = formatAccountToCaipAccountId( + address, + chainId, + this.#deps.logger, + ); + + if (!caipAccountId) { + return; + } + + // Skip the round trip when this address was already registered for this + // session: preview runs on every keystroke in the order form. + if (this.#registeredTradingAddresses.has(caipAccountId)) { + return; + } + + await this.#messenger.call( + 'SubscriptionController:registerAddress', + caipAccountId, + ); + this.#registeredTradingAddresses.add(caipAccountId); + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Trading address registered', + { caipAccountId }, + ); + } catch (error) { + // An unregistered action, an offline client, or a backend refusal all + // land here. None of them is a reason to fail a fee preview. + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Trading address registration skipped', + { + address, + error: ensureError( + error, + 'RewardsIntegrationService.registerTradingAddress', + ).message, + }, + ); + } + } + + /** + * Forget which trading addresses were registered this session. + * + * Called when the selected account changes, so the next preview re-sends the + * registration for the new address rather than assuming the previous one + * still stands. + */ + resetRegisteredTradingAddresses(): void { + this.#registeredTradingAddresses.clear(); + } + /** * Resolve the rewards (VIP + season) discount for the selected account. * diff --git a/packages/perps-controller/src/services/ServiceContext.ts b/packages/perps-controller/src/services/ServiceContext.ts index 8a19411deaf..8664b764b37 100644 --- a/packages/perps-controller/src/services/ServiceContext.ts +++ b/packages/perps-controller/src/services/ServiceContext.ts @@ -1,6 +1,7 @@ import type { PerpsControllerState } from '../PerpsController.js'; import type { Order, + PerpsFeeResolution, PerpsGlobalSnapshotRequest, PerpsSubscriptionFeeWaiverStatus, Position, @@ -83,6 +84,15 @@ export type ServiceContext = { */ subscriptionFeeWaiver?: PerpsSubscriptionFeeWaiverStatus; + /** + * Unified fee resolution for the quote being previewed. + * + * Carries the same blended subscription rate the submit path will charge, so + * a preview quotes what the order actually pays rather than the undiscounted + * builder fee. Omitted when no resolution was computed. + */ + feeResolution?: PerpsFeeResolution; + /** * Callback functions for controller-specific operations */ diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 70efbd4380e..281f1bc7f67 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1803,6 +1803,21 @@ export type PerpsFeeResolution = { /** Subscription gate outcome, always populated for observability. */ subscription: PerpsSubscriptionFeeWaiverStatus; + + /** + * How much of the order the subscription allowance covered, when the + * subscription source won. + * + * `full` waives the whole MetaMask builder fee; `partial` charges the fee on + * the share the allowance did not cover. Absent when another source won. + */ + subscriptionWaiverKind?: 'full' | 'partial'; + + /** + * Order notional (USD) the subscription allowance covered, when the backend + * bounded the allowance and the caller supplied an order notional. + */ + subscriptionCoveredNotionalUsd?: number; }; export type UpdatePositionTPSLParams = { @@ -2083,7 +2098,14 @@ export type PerpsProvider = { setUserFeeDiscount?(discountBips: number | undefined): void; // Full fee resolution context, including attribution source. setUserFeeResolution?(resolution: PerpsFeeResolution | undefined): void; - /** Approve the dedicated subscription builder outside order submission. */ + /** + * Approve the dedicated subscription builder outside order submission. + * + * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid + * marking on the standard builder. Nothing on the order path reads this + * approval any more; it is retained only so the previous design can be + * restored cheaply if cloid marking does not hold up in shadow mode. + */ approveSubscriptionBuilderFee?(): Promise; // HIP-3 (Builder-deployed DEXs) operations - optional for backward compatibility diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index e547d362181..584f88ef0f7 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -29,10 +29,41 @@ import type { } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; +import type { PerpsSubscriptionBenefits } from './index.js'; + +/** + * Read the current profile's subscription benefits. + * + * ADR 0064 moves benefits hydration from the plain DI callback onto the + * messenger. The action is declared structurally rather than imported, because + * `SubscriptionController` does not live in this monorepo yet; a client that + * ships it registers an action with this exact name and signature, and a client + * that does not simply never registers it — {@link RewardsIntegrationService} + * falls back to the injected `subscription` dependency in that case. + */ +export type SubscriptionControllerGetPerpsBenefitsAction = { + type: 'SubscriptionController:getPerpsBenefits'; + handler: () => Promise; +}; + +/** + * Register a trading address against the current subscription profile. + * + * ADR 0064 requires the HyperLiquid trading address to be registered through + * `AddressIndex` at preview time, so a fill decoded off the HL fan-out can be + * attributed back to a profile. The address is CAIP-10. + */ +export type SubscriptionControllerRegisterAddressAction = { + type: 'SubscriptionController:registerAddress'; + handler: (caipAccountId: `${string}:${string}:${string}`) => Promise; +}; + /** * Actions from other controllers that PerpsController is allowed to call. */ export type PerpsControllerAllowedActions = + | SubscriptionControllerGetPerpsBenefitsAction + | SubscriptionControllerRegisterAddressAction | GeolocationControllerGetGeolocationAction | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction diff --git a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts index c9d44d253f7..dbccc8374d2 100644 --- a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts +++ b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts @@ -56,7 +56,9 @@ export const HYPERLIQUID_SCALE_CLOID_MARKER = '4d4d5343'; * @param clientOrderId - HyperLiquid client order ID. * @returns The Scale handle, or undefined for an unrelated order. */ -const readScaleGroupId = (clientOrderId: string | null): string | undefined => { +export const readScaleGroupId = ( + clientOrderId: string | null | undefined, +): string | undefined => { const normalized = clientOrderId?.toLowerCase(); if ( normalized?.length !== 34 || diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts new file mode 100644 index 00000000000..5f5c7b3deb8 --- /dev/null +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -0,0 +1,322 @@ +import { isHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { + BASIS_POINTS_DIVISOR, + BUILDER_FEE_CONFIG, +} from '../constants/hyperLiquidConfig.js'; +import { + SUBSCRIPTION_CLOID_CONFIG, + SUBSCRIPTION_CLOID_FLAGS, +} from '../constants/perpsConfig.js'; +import type { + FeeCalculationResult, + PerpsFeeResolution, + PerpsSubscriptionFeeWaiverStatus, +} from '../types/index.js'; + +/** + * How much of an order the subscription allowance covered. + * + * - `full` — the remaining allowance covered the whole order notional, so the + * MetaMask builder fee is waived outright. + * - `partial` — the allowance covered part of the order, so the fee is charged + * on the uncovered share only. + * - `none` — the gate did not pass, so subscription contributes no rate. + */ +export type PerpsSubscriptionWaiverKind = 'full' | 'partial' | 'none'; + +/** + * The subscription source's contribution to the unified fee comparison. + */ +export type PerpsSubscriptionWaiverRate = { + /** Whether the subscription source produced a usable rate at all. */ + applies: boolean; + + /** Effective MetaMask builder fee in basis points under the waiver. */ + feeBips: number; + + /** How much of the order the allowance covered. */ + kind: PerpsSubscriptionWaiverKind; + + /** Order notional (USD) the allowance actually covered, when bounded. */ + coveredNotionalUsd?: number; +}; + +/** + * Resolve the subscription source's effective fee rate for one order. + * + * ADR 0064 replaces the binary 0-bips waiver with a blended rate: + * + * - `remaining >= orderNotional` → `0` bips; the allowance covers the order. + * - `0 < remaining < orderNotional` → `maxFeeBips * (1 - remaining/orderNotional)`; + * the fee is charged only on the share the allowance did not cover. + * - `remaining <= 0` → the allowance is spent, so the source does not apply. + * + * An absent `remainingNotionalUsd` means the backend did not bound the + * allowance, which stays a full waiver — the pre-existing behavior for an + * eligible gate that reports no cap. An absent or non-positive + * `orderNotionalUsd` means there is no notional to blend against (a pure rate + * preview), which also resolves to the full waiver rate. + * + * Pure, so preview and submit consume exactly the same arithmetic and their + * quoted and charged fees cannot drift. + * + * @param params - The inputs to the blended-rate formula. + * @param params.status - The subscription eligibility gate outcome. + * @param params.maxFeeBips - The default MetaMask builder fee, in basis points. + * @param params.orderNotionalUsd - Order notional (USD), when the caller knows it. + * @returns The subscription source's effective rate and how much it covered. + */ +export function resolveSubscriptionWaiverRate(params: { + status: PerpsSubscriptionFeeWaiverStatus; + maxFeeBips: number; + orderNotionalUsd?: number; +}): PerpsSubscriptionWaiverRate { + const { status, maxFeeBips, orderNotionalUsd } = params; + + if (!status.eligible) { + return { applies: false, feeBips: maxFeeBips, kind: 'none' }; + } + + const remaining = status.remainingNotionalUsd; + + // The backend reported no bound on the allowance, so nothing limits it. + if (remaining === undefined) { + return { applies: true, feeBips: 0, kind: 'full' }; + } + + if (!Number.isFinite(remaining) || remaining <= 0) { + // A reported allowance of zero is spent, whatever the gate said. + return { applies: false, feeBips: maxFeeBips, kind: 'none' }; + } + + // No notional to blend against: a rate-only preview quotes the full waiver. + if ( + orderNotionalUsd === undefined || + !Number.isFinite(orderNotionalUsd) || + orderNotionalUsd <= 0 + ) { + return { applies: true, feeBips: 0, kind: 'full' }; + } + + if (remaining >= orderNotionalUsd) { + return { + applies: true, + feeBips: 0, + kind: 'full', + coveredNotionalUsd: orderNotionalUsd, + }; + } + + return { + applies: true, + feeBips: maxFeeBips * (1 - remaining / orderNotionalUsd), + kind: 'partial', + coveredNotionalUsd: remaining, + }; +} + +/** Hex index of the flag byte inside a cloid string (after `0x` + 4 bytes). */ +const FLAG_BYTE_START = 2 + SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength; + +/** Full length of a venue cloid string: `0x` plus 16 bytes of hex. */ +const CLOID_HEX_LENGTH = 34; + +/** + * Read the flag byte out of a cloid. + * + * @param clientOrderId - A venue client order ID, or nothing. + * @returns The flag byte, or undefined when the id is not a well-formed cloid. + */ +export function readSubscriptionCloidFlags( + clientOrderId: string | null | undefined, +): number | undefined { + const normalized = clientOrderId?.toLowerCase(); + if (normalized?.length !== CLOID_HEX_LENGTH) { + return undefined; + } + const flags = Number.parseInt( + normalized.slice(FLAG_BYTE_START, FLAG_BYTE_START + 2), + 16, + ); + return Number.isNaN(flags) ? undefined : flags; +} + +/** + * Whether a cloid declares that a subscription fee reduction was applied. + * + * This is what the fill fan-out decodes downstream, so it is the honest + * definition of "marked": the program marker alone does not mean a reduction + * was charged. + * + * @param clientOrderId - A venue client order ID, or nothing. + * @returns True when the `fee_reduction_applied` flag is set. + */ +export function hasFeeReductionAppliedFlag( + clientOrderId: string | null | undefined, +): boolean { + const flags = readSubscriptionCloidFlags(clientOrderId); + return ( + flags !== undefined && + isFlagSet(flags, SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied) + ); +} + +/** + * Whether one bit of a flag byte is set. + * + * Written arithmetically rather than with a bitwise `&`: the flag byte is a + * small unsigned integer, so shifting the bit into place and reading its parity + * is exact, and it keeps this file free of bitwise operators the repo's lint + * rules disallow. + * + * @param flags - The flag byte read out of a cloid. + * @param bit - The single-bit flag value to test, e.g. `0x01`. + * @returns True when that bit is set. + */ +function isFlagSet(flags: number, bit: number): boolean { + return Math.floor(flags / bit) % 2 === 1; +} + +/** + * Whether a cloid carries the subscription program marker in its leading bytes. + * + * Only orders that had no cloid of their own get the program marker; an order + * that already carried one (a Scale rung) keeps its own leading marker and + * carries the subscription attribution in the flag byte instead. Decoders + * should therefore key on {@link hasFeeReductionAppliedFlag}, and use this only + * to tell the two layouts apart. + * + * @param clientOrderId - A venue client order ID, or nothing. + * @returns True when the cloid starts with the subscription program id. + */ +export function isSubscriptionProgramCloid( + clientOrderId: string | null | undefined, +): boolean { + const normalized = clientOrderId?.toLowerCase(); + return Boolean( + normalized?.length === CLOID_HEX_LENGTH && + normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`), + ); +} + +/** + * Stamp the subscription marking onto a venue client order ID. + * + * A cloid is 16 bytes, laid out as: + * + * ``` + * 0x + * ``` + * + * The flag byte sits *after* the leading marker rather than replacing it, which + * is what lets the marking compose with the cloid the Scale ladder already + * builds. Two cases: + * + * - **No existing cloid** — the leading bytes become + * {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId} and the rest is fresh entropy. + * - **An existing cloid** (a Scale rung) — its own leading marker and its + * trailing bytes are preserved, and only the flag byte is set. The Scale + * group marker still prefixes the id, so group recovery from open orders and + * cancel-by-cloid keep working, and the rung index in the last byte still + * keeps every rung unique. + * + * A cloid is only ever marked when the subscription source actually won, so any + * other fee source leaves the id exactly as the caller built it. + * + * @param params - The marking inputs. + * @param params.clientOrderId - The cloid the caller already chose, if any. + * @param params.entropy - Hex entropy used when there is no existing cloid. + * @returns The marked cloid. + */ +export function markSubscriptionCloid(params: { + clientOrderId?: string; + entropy: string; +}): Hex { + const { clientOrderId, entropy } = params; + const flags = SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied.toString( + 16, + ).padStart(2, '0'); + + let body: string; + if (clientOrderId?.length === CLOID_HEX_LENGTH) { + const existing = clientOrderId.slice(2).toLowerCase(); + // Keep the caller's marker and trailing bytes; claim only the flag byte. + body = `${existing.slice(0, SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength)}${flags}${existing.slice( + SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength + 2, + )}`; + } else { + const suffix = entropy + .toLowerCase() + .replace(/[^0-9a-f]/gu, '') + .slice(0, SUBSCRIPTION_CLOID_CONFIG.EntropyHexLength) + .padEnd(SUBSCRIPTION_CLOID_CONFIG.EntropyHexLength, '0'); + body = `${SUBSCRIPTION_CLOID_CONFIG.ProgramId}${flags}${suffix}`; + } + + const marked: Hex = `0x${body}`; + + if (!isHexString(marked) || marked.length !== CLOID_HEX_LENGTH) { + throw new Error('Failed to mark subscription client order ID'); + } + + return marked; +} + +/** + * Re-price a fee quote from the unified fee resolution. + * + * The provider quotes the MetaMask component from whatever discount the last + * submit pushed into it, which knows nothing about the notional being quoted. + * The resolver does, so the preview replaces the MetaMask component with the + * resolved rate and rebuilds the total from it. That is what makes a quoted + * blended fee equal the fee the order will actually be charged. + * + * A quote whose placement carries no builder fee at all (`metamaskFeeRate` of + * `0`, e.g. TWAP) is left alone: there is no MetaMask fee to discount. + * + * @param params - The re-pricing inputs. + * @param params.fees - The provider's fee quote. + * @param params.resolution - The unified fee resolution, when one was computed. + * @param params.amount - Order notional (USD) as a string, when provided. + * @returns The quote with its MetaMask component and totals re-priced. + */ +export function applyFeeResolution(params: { + fees: FeeCalculationResult; + resolution: PerpsFeeResolution | undefined; + amount?: string; +}): FeeCalculationResult { + const { fees, resolution, amount } = params; + + if ( + resolution?.discountBips === undefined || + fees.metamaskFeeRate === undefined || + fees.metamaskFeeRate === 0 + ) { + return fees; + } + + const baseMetamaskFeeRate = BUILDER_FEE_CONFIG.MaxFeeDecimal; + const metamaskFeeRate = + baseMetamaskFeeRate * (1 - resolution.discountBips / BASIS_POINTS_DIVISOR); + const parsedAmount = + amount === undefined ? undefined : Number.parseFloat(amount); + const notional = + parsedAmount !== undefined && Number.isFinite(parsedAmount) + ? parsedAmount + : undefined; + + const protocolFeeRate = fees.protocolFeeRate ?? 0; + const feeRate = protocolFeeRate + metamaskFeeRate; + + return { + ...fees, + metamaskFeeRate, + feeRate, + ...(notional !== undefined && { + metamaskFeeAmount: notional * metamaskFeeRate, + feeAmount: notional * feeRate, + }), + }; +} diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 4738d2dbbf1..e8693864973 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1518,21 +1518,6 @@ describe('PerpsController', () => { }); describe('fee calculations', () => { - it('approves the subscription builder outside order submission', async () => { - mockProvider.approveSubscriptionBuilderFee = jest - .fn() - .mockResolvedValue(true); - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - expect(mockProvider.approveSubscriptionBuilderFee).toHaveBeenCalledTimes( - 1, - ); - }); - it('calculates fees', async () => { const feeParams = { orderType: 'market' as const, @@ -1674,6 +1659,183 @@ describe('PerpsController', () => { refresh.mockRestore(); }); + it('quotes the same blended rate the submit path charges', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + // The order notional is what makes a blend possible at all. + amount: '1000', + symbol: 'BTC', + }; + const resolution = { + feeBips: 7.5, + discountBips: 2500, + source: 'subscription' as const, + subscription: { + eligible: true, + reason: 'eligible' as const, + remainingNotionalUsd: 250, + }, + subscriptionWaiverKind: 'partial' as const, + subscriptionCoveredNotionalUsd: 250, + }; + const resolveFee = jest + .spyOn(RewardsIntegrationService.prototype, 'resolveFee') + .mockResolvedValue(resolution); + jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees(feeParams); + + // The preview resolves against this quote's own notional, so the rate it + // quotes is the rate the order is charged. + expect(resolveFee).toHaveBeenCalledWith(1000); + const { context } = ( + mockMarketDataServiceInstance.calculateFees as jest.Mock + ).mock.calls.at(-1)[0]; + expect(context.feeResolution).toStrictEqual(resolution); + + jest.restoreAllMocks(); + }); + + it('resolves without an order notional when the preview quotes a bare rate', async () => { + const resolveFee = jest + .spyOn(RewardsIntegrationService.prototype, 'resolveFee') + .mockResolvedValue({ + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }); + jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees({ + orderType: 'market', + isMaker: false, + symbol: 'BTC', + }); + + expect(resolveFee).toHaveBeenCalledWith(undefined); + + jest.restoreAllMocks(); + }); + + it('registers the current HyperLiquid address at preview time', async () => { + const register = jest + .spyOn(RewardsIntegrationService.prototype, 'registerTradingAddress') + .mockResolvedValue(undefined); + jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '1000', + symbol: 'BTC', + }); + + expect(register).toHaveBeenCalledWith(expect.stringMatching(/^0x/u)); + + jest.restoreAllMocks(); + }); + + it('never fails a fee preview when address registration rejects', async () => { + jest + .spyOn(RewardsIntegrationService.prototype, 'registerTradingAddress') + .mockRejectedValue(new Error('address index unavailable')); + jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '1000', + symbol: 'BTC', + }), + ).resolves.toBeDefined(); + + jest.restoreAllMocks(); + }); + + it('re-registers the trading address when the selected account changes', async () => { + const reset = jest + .spyOn( + RewardsIntegrationService.prototype, + 'resetRegisteredTradingAddresses', + ) + .mockImplementation(() => undefined); + + // A controller built with a messenger this test holds, so the + // lifetime subscription registered in the constructor is observable. + const messenger = createMockMessenger(); + const subscribed = new TestablePerpsController({ + messenger, + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + expect(subscribed).toBeDefined(); + + const accountHandlers = (messenger.subscribe as jest.Mock).mock.calls + .filter( + ([event]) => event === 'AccountsController:selectedAccountChange', + ) + .map(([, handler]) => handler as () => void); + expect(accountHandlers.length).toBeGreaterThan(0); + + accountHandlers.forEach((handler) => handler()); + + // The session's registrations are dropped, so the next preview announces + // the new address instead of assuming the previous one still stands. + expect(reset).toHaveBeenCalled(); + + reset.mockRestore(); + }); + + it('no longer approves a dedicated subscription builder', async () => { + const approve = jest.fn().mockResolvedValue(true); + mockProvider.approveSubscriptionBuilderFee = approve; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + // ADR 0064 replaced the dedicated builder with cloid marking, so this + // stays a no-op rather than reaching the provider — even when the + // provider still exposes the old approval method. + await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( + false, + ); + expect(approve).not.toHaveBeenCalled(); + }); + it('exposes subscription benefits invalidation to clients', async () => { // The service is private to the controller, so a client detecting a // sign-out or profile switch can only reach it through this method. diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index bfde2762261..497664531bf 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -2572,9 +2572,15 @@ describe('PerpsController', () => { preloadController.startMarketDataPreload(); await Promise.resolve(); - const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( - ([event]) => event === 'AccountsController:selectedAccountChange', - )?.[1] as (() => void) | undefined; + // Two handlers subscribe to this event: the controller's lifetime + // subscription-registration reset (registered in the constructor) and the + // preload refresh registered by startMarketDataPreload. This test drives + // the preload one, which is the later registration. + const accountChangeHandler = preloadMessenger.subscribe.mock.calls + .filter( + ([event]) => event === 'AccountsController:selectedAccountChange', + ) + .at(-1)?.[1] as (() => void) | undefined; mockEvmAccount.address = secondAddress; accountChangeHandler?.(); firstRequest.resolve(firstSnapshot); @@ -3135,9 +3141,15 @@ describe('PerpsController', () => { preloadController.startMarketDataPreload(); await jest.advanceTimersByTimeAsync(500); - const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( - ([event]) => event === 'AccountsController:selectedAccountChange', - )?.[1] as (() => void) | undefined; + // Two handlers subscribe to this event: the controller's lifetime + // subscription-registration reset (registered in the constructor) and the + // preload refresh registered by startMarketDataPreload. This test drives + // the preload one, which is the later registration. + const accountChangeHandler = preloadMessenger.subscribe.mock.calls + .filter( + ([event]) => event === 'AccountsController:selectedAccountChange', + ) + .at(-1)?.[1] as (() => void) | undefined; expect(accountChangeHandler).toBeDefined(); mockEvmAccount.address = secondAddress; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index c4ccf99fe16..5dcbf4f0b5c 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -32,6 +32,10 @@ import { validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + hasFeeReductionAppliedFlag, + isSubscriptionProgramCloid, +} from '../../../src/utils/subscriptionFeeWaiver.js'; import { createMockInfrastructure, createMockMessenger, @@ -701,9 +705,9 @@ describe('HyperLiquidProvider', () => { ); }); - it('routes an approved subscription waiver through the dedicated builder', async () => { - // Builder fee already approved: this test is about the fee value on the - // signed payload, not the approval flow. + it('keeps the standard builder address when subscription wins', async () => { + // ADR 0064 replaced the dedicated subscription builder with cloid + // marking, so even an approved subscription builder must not be selected. mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ maxBuilderFee: jest.fn().mockResolvedValue(0.001), @@ -745,6 +749,7 @@ describe('HyperLiquidProvider', () => { discountBips: 10000, source: 'subscription', subscription: { eligible: true, reason: 'eligible' }, + subscriptionWaiverKind: 'full', }); const waived = await provider.placeOrder(orderParams); @@ -752,56 +757,34 @@ describe('HyperLiquidProvider', () => { expect(waived.success).toBe(true); expect(exchangeClient.order).toHaveBeenCalledWith( expect.objectContaining({ - builder: { b: subscriptionBuilder, f: 0 }, + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + // A full waiver is charged as a zero fee on the standard builder. + f: 0, + }, }), ); - }); - - it('initializes clients before approving the subscription builder', async () => { - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - maxBuilderFee: jest.fn().mockResolvedValue(0.001), + expect(exchangeClient.order).not.toHaveBeenCalledWith( + expect.objectContaining({ + builder: expect.objectContaining({ b: subscriptionBuilder }), }), ); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - - await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - - expect(mockClientService.initialize).toHaveBeenCalledTimes(1); - expect(mockClientService.getInfoClient).toHaveBeenCalled(); }); - it('does not reuse subscription builder approval after an account switch', async () => { - const accountA = '0x1234567890123456789012345678901234567890'; - const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - const exchangeClient = mockClientService.getExchangeClient(); + it('charges a blended subscription fee on the standard builder', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ maxBuilderFee: jest.fn().mockResolvedValue(0.001), }), ); - mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - - await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - - mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); - (exchangeClient.order as jest.Mock).mockClear(); + const exchangeClient = mockClientService.getExchangeClient(); provider.setUserFeeResolution({ - feeBips: 0, - discountBips: 10000, + feeBips: 7.5, + // 7.5 of 10 bips = a 25% discount off the default builder fee. + discountBips: 2500, source: 'subscription', subscription: { eligible: true, reason: 'eligible' }, + subscriptionWaiverKind: 'partial', }); const result = await provider.placeOrder({ @@ -817,12 +800,31 @@ describe('HyperLiquidProvider', () => { expect.objectContaining({ builder: { b: BUILDER_FEE_CONFIG.MainnetBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + f: Math.floor(BUILDER_FEE_CONFIG.MaxFeeTenthsBps * 0.75), }, }), ); }); + it('initializes clients before approving the subscription builder', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + }), + ); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + it('keeps subscription approval reads scoped to the initiating account', async () => { const accountA = '0x1234567890123456789012345678901234567890'; const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; @@ -926,49 +928,6 @@ describe('HyperLiquidProvider', () => { ).resolves.toStrictEqual([true, true]); }); - it('falls back to the standard fee when the subscription builder is not approved', async () => { - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - const defaultBuilder = BUILDER_FEE_CONFIG.MainnetBuilder; - const exchangeClient = mockClientService.getExchangeClient(); - const maxBuilderFee = jest.fn().mockResolvedValue(0.001); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - maxBuilderFee, - }), - ); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - provider.setUserFeeResolution({ - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { eligible: true, reason: 'eligible' }, - }); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(result.success).toBe(true); - expect(exchangeClient.order).toHaveBeenCalledWith( - expect.objectContaining({ - builder: { - b: defaultBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }, - }), - ); - expect(maxBuilderFee).not.toHaveBeenCalledWith( - expect.objectContaining({ builder: subscriptionBuilder }), - ); - expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); - }); - it('includes builder fee and referral setup in TP/SL updates', async () => { // Mock builder fee not approved to trigger approval call mockClientService.getInfoClient = jest.fn().mockReturnValue({ @@ -1942,4 +1901,136 @@ describe('HyperLiquidProvider', () => { expect(mockCompleteInFlight).toHaveBeenCalled(); }); }); + describe('ADR 0064 subscription cloid marking', () => { + const subscriptionResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription' as const, + subscription: { eligible: true, reason: 'eligible' as const }, + subscriptionWaiverKind: 'full' as const, + }; + + const rewardsResolution = { + feeBips: 6.5, + discountBips: 3500, + source: 'rewards' as const, + subscription: { eligible: false, reason: 'not-entitled' as const }, + }; + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + /** + * Read every submitted order payload out of the exchange client mock. + * + * @param exchangeClient - The mocked exchange client. + * @returns Every order payload submitted through `order`. + */ + const submittedOrders = (exchangeClient: { + order: jest.Mock; + }): { c?: string }[] => + exchangeClient.order.mock.calls.flatMap( + (call) => (call[0] as { orders: { c?: string }[] }).orders, + ); + + beforeEach(() => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }), + ); + }); + + it('marks the cloid with the subscription program id when subscription wins', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + provider.setUserFeeResolution(subscriptionResolution); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + const orders = submittedOrders(exchangeClient as never); + expect(orders.length).toBeGreaterThan(0); + orders.forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(true); + expect(isSubscriptionProgramCloid(order.c)).toBe(true); + }); + }); + + it('leaves the cloid unmarked when any other fee source wins', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + + // Rewards wins. + provider.setUserFeeResolution(rewardsResolution); + await provider.placeOrder(orderParams); + + // Default wins (nothing resolved at all). + provider.setUserFeeResolution(undefined); + await provider.placeOrder(orderParams); + + const orders = submittedOrders(exchangeClient as never); + expect(orders.length).toBeGreaterThan(0); + orders.forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(false); + expect(isSubscriptionProgramCloid(order.c)).toBe(false); + }); + }); + + it('marks the cloid on a TP/SL placement path', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + provider.setUserFeeResolution(subscriptionResolution); + + const result = await provider.placeOrder({ + ...orderParams, + takeProfitPrice: '55000', + stopLossPrice: '45000', + }); + + expect(result.success).toBe(true); + const orders = submittedOrders(exchangeClient as never); + // Main order plus its attached TP and SL children. + expect(orders.length).toBeGreaterThanOrEqual(3); + orders.forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(true); + }); + }); + + it('marks the cloid on a position TP/SL update path', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + provider.setUserFeeResolution(subscriptionResolution); + + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }); + + const orders = submittedOrders(exchangeClient as never); + expect(orders.length).toBeGreaterThan(0); + orders.forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(true); + }); + }); + + it('keeps each marked order id unique within one submission', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + provider.setUserFeeResolution(subscriptionResolution); + + await provider.placeOrder({ + ...orderParams, + takeProfitPrice: '55000', + stopLossPrice: '45000', + }); + + const ids = submittedOrders(exchangeClient as never).map( + (order) => order.c, + ); + expect(new Set(ids).size).toBe(ids.length); + }); + }); }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index ecde4f2058f..a6cf1f7f432 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -25,6 +25,7 @@ import type { OrderResult, } from '../../../src/types/index.js'; import type { OrderType } from '../../../src/types/perps-types.js'; +import { HYPERLIQUID_SCALE_CLOID_MARKER } from '../../../src/utils/hyperLiquidAdapter.js'; import { validateAssetSupport, validateBalance, @@ -33,6 +34,7 @@ import { validateOrderParams, validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; +import { hasFeeReductionAppliedFlag } from '../../../src/utils/subscriptionFeeWaiver.js'; import { createMockPosition } from '../../helpers/providerMocks.js'; import { createDeferred, @@ -3441,6 +3443,64 @@ describe('HyperLiquidProvider - strategy order types', () => { ).toStrictEqual(['2000', '2500', '3000']); }); + it('marks every rung cloid when subscription wins and keeps the ladder recoverable', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + subscriptionWaiverKind: 'full', + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + const cloids = submitted.orders.map((order: { c?: string }) => order.c); + + // Every rung carries the attribution... + cloids.forEach((cloid: string) => { + expect(hasFeeReductionAppliedFlag(cloid)).toBe(true); + // ...while keeping the Scale marker, so the group stays recoverable + // from open orders and cancel-by-cloid keeps working. + expect(cloid.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`)).toBe( + true, + ); + }); + // And each rung is still a distinct id. + expect(new Set(cloids).size).toBe(3); + }); + + it('leaves rung cloids unmarked when subscription did not win', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + submitted.orders.forEach((order: { c?: string }) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(false); + expect(order.c?.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`)).toBe( + true, + ); + }); + }); + it('submits the provider preview prices for fractional bounds', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index dfc584573e1..38143dd4608 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -33,6 +33,10 @@ import { validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + hasFeeReductionAppliedFlag, + isSubscriptionProgramCloid, +} from '../../../src/utils/subscriptionFeeWaiver.js'; import { createMockInfrastructure, createMockMessenger, @@ -4874,4 +4878,136 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(true); }); }); + + describe('ADR 0064 subscription cloid marking on replace and batch paths', () => { + const subscriptionResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription' as const, + subscription: { eligible: true, reason: 'eligible' as const }, + subscriptionWaiverKind: 'full' as const, + }; + + const rewardsResolution = { + feeBips: 6.5, + discountBips: 3500, + source: 'rewards' as const, + subscription: { eligible: false, reason: 'not-entitled' as const }, + }; + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + price: '52000', + orderType: 'limit', + } as OrderParams, + }; + + it('marks the cloid with the subscription program id when subscription wins', async () => { + provider.setUserFeeResolution(subscriptionResolution); + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(true); + const modifyCalls = ( + mockClientService.getExchangeClient().modify as jest.Mock + ).mock.calls; + expect(modifyCalls.length).toBeGreaterThan(0); + modifyCalls.forEach(([payload]) => { + const cloid = (payload as { order: { c?: string } }).order.c; + expect(hasFeeReductionAppliedFlag(cloid)).toBe(true); + expect(isSubscriptionProgramCloid(cloid)).toBe(true); + }); + }); + + it('marks the cloid on the batch close path', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1500', accountValue: '11500' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.5', + entryPx: '50000', + positionValue: '75000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '-2.0', + entryPx: '3000', + positionValue: '6000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '3300', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11500', + totalMarginUsed: '1500', + }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }), + ); + provider.setUserFeeResolution(subscriptionResolution); + + await provider.closePositions({ closeAll: true }); + + const batchOrders = ( + mockClientService.getExchangeClient().order as jest.Mock + ).mock.calls.flatMap( + (call) => (call[0] as { orders: { c?: string }[] }).orders, + ); + expect(batchOrders.length).toBeGreaterThan(0); + batchOrders.forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(true); + }); + // Every order in the batch still needs its own id. + expect(new Set(batchOrders.map((order) => order.c)).size).toBe( + batchOrders.length, + ); + }); + + it('leaves the cloid unmarked when any other fee source wins', async () => { + provider.setUserFeeResolution(rewardsResolution); + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(true); + const modifyCalls = ( + mockClientService.getExchangeClient().modify as jest.Mock + ).mock.calls; + expect(modifyCalls.length).toBeGreaterThan(0); + modifyCalls.forEach(([payload]) => { + expect( + hasFeeReductionAppliedFlag( + (payload as { order: { c?: string } }).order.c, + ), + ).toBe(false); + }); + }); + }); }); diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 3e9ae3b1f17..2658f128da8 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -715,6 +715,307 @@ describe('RewardsIntegrationService', () => { expect(await service.calculateUserFeeDiscount()).toBe(10000); }); + + it('waives the whole fee when the remaining allowance covers the order notional', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 5000 })), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBe(0); + expect(resolution.discountBips).toBe(10000); + expect(resolution.subscriptionWaiverKind).toBe('full'); + expect(resolution.subscriptionCoveredNotionalUsd).toBe(1000); + }); + + it('waives the whole fee when the remaining allowance exactly equals the order notional', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 1000 })), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBe(0); + expect(resolution.subscriptionWaiverKind).toBe('full'); + }); + + it('blends the fee by the uncovered share when the allowance is smaller than the order notional', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })), + ); + // Rewards resolved but worthless, so only the blend can beat the default. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + // 10 bips * (1 - 250/1000) = 7.5 bips, i.e. a 25% discount. + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBeCloseTo(7.5, 10); + expect(resolution.discountBips).toBe(2500); + expect(resolution.subscriptionWaiverKind).toBe('partial'); + expect(resolution.subscriptionCoveredNotionalUsd).toBe(250); + }); + + it('lets a rewards discount beat a partial subscription blend', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 100 })), + ); + // A partial blend of 10 * (1 - 100/1000) = 9 bips, against a VIP/season + // discount worth 6.5 bips. The blend has to lose. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(3500); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBeCloseTo(6.5, 10); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + // The gate still passed — subscription simply lost on price. + expect(resolution.subscription.eligible).toBe(true); + }); + + it('lets a partial subscription blend win when it undercuts rewards', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 900 })), + ); + // Blend is 10 * (1 - 900/1000) = 1 bip, cheaper than the 6.5 bips VIP. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(3500); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBeCloseTo(1, 10); + expect(resolution.subscriptionWaiverKind).toBe('partial'); + }); + + it('withholds the waiver when the allowance is exhausted', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue( + createBenefits({ usage: 'exhausted', remainingNotionalUsd: 0 }), + ), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); + expect(resolution.subscription.reason).toBe('exhausted'); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + }); + + it('withholds the waiver when an eligible gate reports a spent allowance', async () => { + // The gate can pass while the reported allowance is already zero; the + // blend would otherwise charge the full fee under a `subscription` label. + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 0 })), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + }); + + it('withholds the blend when the cached snapshot is hard-stale', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + jest.setSystemTime(NOW + MAX_STALE_MS + 1); + + const resolution = await service.resolveFee(1000); + + expect(resolution.subscription.reason).toBe('stale'); + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); + }); + + it('quotes the full waiver when no order notional is supplied', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + + // A rate-only preview has nothing to blend against. + const resolution = await service.resolveFee(); + + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBe(0); + expect(resolution.subscriptionWaiverKind).toBe('full'); + }); + + it('drops the subscription source when the remote feature flag disables it', async () => { + setupMessengerDefaults({ + 'RemoteFeatureFlagController:getState': { + remoteFeatureFlags: { perpsSubscriptionFeeWaiverEnabled: false }, + }, + }); + wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(3500); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'no-source', + }); + // Only subscription is killed: rewards and default are untouched. + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBeCloseTo(6.5, 10); + }); + + it('keeps the subscription source when the remote flag is enabled or absent', async () => { + setupMessengerDefaults({ + 'RemoteFeatureFlagController:getState': { + remoteFeatureFlags: { perpsSubscriptionFeeWaiverEnabled: true }, + }, + }); + wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(3500); + await service.refreshSubscriptionBenefits(); + + expect(await service.resolveFee(1000)).toMatchObject({ + source: 'subscription', + feeBips: 0, + }); + }); + + it('reads benefits through the SubscriptionController action when one is registered', async () => { + const messengerBenefits = jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })); + setupMessengerDefaults({ + 'SubscriptionController:getPerpsBenefits': messengerBenefits, + }); + const diBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + await service.refreshSubscriptionBenefits(); + const resolution = await service.resolveFee(1000); + + expect(messengerBenefits).toHaveBeenCalled(); + expect(diBenefits).not.toHaveBeenCalled(); + expect(resolution.subscriptionWaiverKind).toBe('partial'); + }); + + it('falls back to the injected benefits source when no SubscriptionController action is registered', async () => { + const diBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + + await service.refreshSubscriptionBenefits(); + + expect(diBenefits).toHaveBeenCalled(); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + }); + + it('registers the trading address once per address and again after a reset', async () => { + const registerAddress = jest.fn().mockResolvedValue(undefined); + setupMessengerDefaults({ + 'SubscriptionController:registerAddress': registerAddress, + }); + wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + + await service.registerTradingAddress(mockEvmAccount.address); + await service.registerTradingAddress(mockEvmAccount.address); + + expect(registerAddress).toHaveBeenCalledTimes(1); + expect(registerAddress).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:1:0x/u), + ); + + // Account switch: the new address has to announce itself. + service.resetRegisteredTradingAddresses(); + await service.registerTradingAddress(mockEvmAccount.address); + + expect(registerAddress).toHaveBeenCalledTimes(2); + }); + + it('never throws when address registration is unavailable', async () => { + setupMessengerDefaults({ + 'SubscriptionController:registerAddress': () => { + throw new Error('action not registered'); + }, + }); + wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + + await expect( + service.registerTradingAddress(mockEvmAccount.address), + ).resolves.toBeUndefined(); + }); + + it('skips address registration entirely without a subscription source', async () => { + const registerAddress = jest.fn().mockResolvedValue(undefined); + setupMessengerDefaults({ + 'SubscriptionController:registerAddress': registerAddress, + }); + + await service.registerTradingAddress(mockEvmAccount.address); + + expect(registerAddress).not.toHaveBeenCalled(); + }); }); describe('instance isolation', () => { diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts new file mode 100644 index 00000000000..a06cf4b65d5 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -0,0 +1,312 @@ +import { + SUBSCRIPTION_CLOID_CONFIG, + SUBSCRIPTION_CLOID_FLAGS, +} from '../../../src/constants/perpsConfig.js'; +import type { PerpsSubscriptionFeeWaiverStatus } from '../../../src/types/index.js'; +import { HYPERLIQUID_SCALE_CLOID_MARKER } from '../../../src/utils/hyperLiquidAdapter.js'; +import { + applyFeeResolution, + hasFeeReductionAppliedFlag, + isSubscriptionProgramCloid, + markSubscriptionCloid, + readSubscriptionCloidFlags, + resolveSubscriptionWaiverRate, +} from '../../../src/utils/subscriptionFeeWaiver.js'; + +/** 10 bips = BUILDER_FEE_CONFIG.MaxFeeDecimal (0.001) * BASIS_POINTS_DIVISOR. */ +const MAX_FEE_BIPS = 10; + +/** + * Build an eligibility-gate outcome that passes by default. + * + * @param overrides - Fields to override on the status. + * @returns A subscription fee-waiver status. + */ +const createStatus = ( + overrides: Partial = {}, +): PerpsSubscriptionFeeWaiverStatus => ({ + eligible: true, + reason: 'eligible', + ...overrides, +}); + +describe('resolveSubscriptionWaiverRate', () => { + it('waives the whole fee when the remaining allowance covers the order notional', () => { + expect( + resolveSubscriptionWaiverRate({ + status: createStatus({ remainingNotionalUsd: 5000 }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }), + ).toStrictEqual({ + applies: true, + feeBips: 0, + kind: 'full', + coveredNotionalUsd: 1000, + }); + }); + + it('waives the whole fee at the exact boundary where remaining equals the notional', () => { + expect( + resolveSubscriptionWaiverRate({ + status: createStatus({ remainingNotionalUsd: 1000 }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }), + ).toMatchObject({ applies: true, feeBips: 0, kind: 'full' }); + }); + + it('blends the fee by the uncovered share when the allowance is smaller than the order notional', () => { + const rate = resolveSubscriptionWaiverRate({ + status: createStatus({ remainingNotionalUsd: 250 }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }); + + // 10 * (1 - 250/1000) = 7.5 bips — the fee on the uncovered 750 USD. + expect(rate.applies).toBe(true); + expect(rate.feeBips).toBeCloseTo(7.5, 10); + expect(rate.kind).toBe('partial'); + expect(rate.coveredNotionalUsd).toBe(250); + }); + + it('charges almost the full fee when the allowance barely covers the order', () => { + const rate = resolveSubscriptionWaiverRate({ + status: createStatus({ remainingNotionalUsd: 1 }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }); + + expect(rate.feeBips).toBeCloseTo(9.99, 10); + expect(rate.kind).toBe('partial'); + }); + + it('does not apply when the gate did not pass', () => { + expect( + resolveSubscriptionWaiverRate({ + status: createStatus({ eligible: false, reason: 'exhausted' }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }), + ).toStrictEqual({ applies: false, feeBips: MAX_FEE_BIPS, kind: 'none' }); + }); + + it('does not apply when an eligible gate reports a spent allowance', () => { + expect( + resolveSubscriptionWaiverRate({ + status: createStatus({ remainingNotionalUsd: 0 }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }), + ).toStrictEqual({ applies: false, feeBips: MAX_FEE_BIPS, kind: 'none' }); + }); + + it('treats an unbounded allowance as a full waiver', () => { + expect( + resolveSubscriptionWaiverRate({ + status: createStatus(), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd: 1000, + }), + ).toStrictEqual({ applies: true, feeBips: 0, kind: 'full' }); + }); + + it.each([undefined, 0, -100, Number.NaN])( + 'quotes the full waiver rate when the order notional is %p', + (orderNotionalUsd) => { + expect( + resolveSubscriptionWaiverRate({ + status: createStatus({ remainingNotionalUsd: 250 }), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd, + }), + ).toMatchObject({ applies: true, feeBips: 0, kind: 'full' }); + }, + ); +}); + +describe('markSubscriptionCloid', () => { + it('marks a fresh cloid with the subscription program id and the fee-reduction flag', () => { + const cloid = markSubscriptionCloid({ + entropy: 'abcdef0123456789abcdef0123456789', + }); + + expect(cloid).toHaveLength(34); + expect(isSubscriptionProgramCloid(cloid)).toBe(true); + expect(hasFeeReductionAppliedFlag(cloid)).toBe(true); + expect(readSubscriptionCloidFlags(cloid)).toBe( + SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied, + ); + expect(cloid.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`)).toBe( + true, + ); + }); + + it('pads short entropy rather than producing a malformed cloid', () => { + const cloid = markSubscriptionCloid({ entropy: 'abc' }); + + expect(cloid).toHaveLength(34); + expect(hasFeeReductionAppliedFlag(cloid)).toBe(true); + }); + + it('produces distinct cloids for distinct entropy', () => { + const first = markSubscriptionCloid({ entropy: '1'.repeat(32) }); + const second = markSubscriptionCloid({ entropy: '2'.repeat(32) }); + + expect(first).not.toStrictEqual(second); + }); + + it('preserves an existing Scale cloid marker and its rung index', () => { + // A Scale rung: 4-byte scale marker, group entropy, rung index last byte. + const rung = + `0x${HYPERLIQUID_SCALE_CLOID_MARKER}00${'ab'.repeat(10)}07` as const; + expect(rung).toHaveLength(34); + + const marked = markSubscriptionCloid({ + clientOrderId: rung, + entropy: 'ffffffffffffffffffffffffffffffff', + }); + + expect(marked).toHaveLength(34); + // The Scale group marker still prefixes the id, so group recovery and + // cancel-by-cloid keep working. + expect(marked.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`)).toBe(true); + // The rung index survives, so rungs cannot collide on one cloid. + expect(marked.slice(-2)).toBe('07'); + // And the attribution is carried in the flag byte instead. + expect(hasFeeReductionAppliedFlag(marked)).toBe(true); + expect(isSubscriptionProgramCloid(marked)).toBe(false); + }); + + it('keeps every marked rung of one ladder distinct', () => { + const marked = [0, 1, 2].map((index) => + markSubscriptionCloid({ + clientOrderId: + `0x${HYPERLIQUID_SCALE_CLOID_MARKER}00${'ab'.repeat(10)}${index + .toString(16) + .padStart(2, '0')}` as const, + entropy: 'ffffffffffffffffffffffffffffffff', + }), + ); + + expect(new Set(marked).size).toBe(3); + }); +}); + +describe('hasFeeReductionAppliedFlag', () => { + it.each([undefined, null, '', '0xdeadbeef'])( + 'reports no flag for %p', + (clientOrderId) => { + expect(hasFeeReductionAppliedFlag(clientOrderId)).toBe(false); + }, + ); + + it('reports no flag for an unmarked Scale cloid, whose flag byte is reserved', () => { + // The Scale generator zeroes the byte the subscription flag lives in, so + // an unmarked ladder can never decode downstream as a waived one. + const unmarked = `0x${HYPERLIQUID_SCALE_CLOID_MARKER}00${'ab'.repeat(10)}07`; + + expect(unmarked).toHaveLength(34); + expect(hasFeeReductionAppliedFlag(unmarked)).toBe(false); + expect(readSubscriptionCloidFlags(unmarked)).toBe(0); + }); +}); + +describe('applyFeeResolution', () => { + const fees = { + feeRate: 0.00145, + feeAmount: 1.45, + protocolFeeRate: 0.00045, + protocolFeeAmount: 0.45, + metamaskFeeRate: 0.001, + metamaskFeeAmount: 1, + }; + + it('re-prices the MetaMask component and the total from a blended resolution', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + // 7.5 bips of a 10 bips default = a 25% discount. + feeBips: 7.5, + discountBips: 2500, + source: 'subscription', + subscription: createStatus({ remainingNotionalUsd: 250 }), + subscriptionWaiverKind: 'partial', + }, + amount: '1000', + }); + + expect(priced.metamaskFeeRate).toBeCloseTo(0.00075, 10); + expect(priced.feeRate).toBeCloseTo(0.0012, 10); + expect(priced.metamaskFeeAmount).toBeCloseTo(0.75, 10); + expect(priced.feeAmount).toBeCloseTo(1.2, 10); + }); + + it('zeroes the MetaMask component on a full waiver', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: createStatus(), + subscriptionWaiverKind: 'full', + }, + amount: '1000', + }); + + expect(priced.metamaskFeeRate).toBe(0); + expect(priced.feeRate).toBeCloseTo(0.00045, 10); + expect(priced.metamaskFeeAmount).toBe(0); + }); + + it('leaves the quote untouched when no source resolved', () => { + expect( + applyFeeResolution({ + fees, + resolution: { + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }), + ).toStrictEqual(fees); + }); + + it('leaves a placement that carries no builder fee untouched', () => { + const twapFees = { ...fees, metamaskFeeRate: 0, metamaskFeeAmount: 0 }; + + expect( + applyFeeResolution({ + fees: twapFees, + resolution: { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: createStatus(), + subscriptionWaiverKind: 'full', + }, + amount: '1000', + }), + ).toStrictEqual(twapFees); + }); + + it('re-prices rates without amounts when no notional was supplied', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: createStatus(), + subscriptionWaiverKind: 'full', + }, + }); + + expect(priced.metamaskFeeRate).toBe(0); + // The previously quoted amounts are left as the provider reported them. + expect(priced.metamaskFeeAmount).toBe(fees.metamaskFeeAmount); + }); +}); From f0a1641a3f74c1c5b9683461e895718964aa4145 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 17 Sep 2026 16:59:13 +0800 Subject: [PATCH 02/21] fix: address self-review feedback (TAT-3967) Two blockers the original test suite did not cover. The submit path resolved the fee with no order notional, so every bounded allowance took the resolver's "no notional to blend against" branch and came back as a full waiver. A 250 USD allowance against a 1000 USD order was quoted 7.5 bips by calculateFees and charged 0 at submit, over-consuming the allowance and stamping the cloid as fully waived. Thread the notional through #calculateFeeDiscountWithMeasurement to all six submit paths, priced from the parameters already in scope: usdAmount where the hybrid model supplies it, otherwise size times the best available price. A batch close is priced from the sum of the positions it will close, since HyperLiquid takes one builder context for the whole batch. An order that cannot be priced passes undefined rather than a guess, which is the behaviour it had before. registerTradingAddress early-returned on the injected subscription dependency, gating the messenger call on the very callback it was written to replace, so a client shipping SubscriptionController without the dependency registered nothing. Drop the guard and let the existing catch absorb an unregistered action. Writing the test surfaced a second defect: a messenger that answers an unregistered action with undefined would have cached the address as registered, so a SubscriptionController wired after the first preview would never receive it. An unhandled call no longer touches the dedupe cache. Also rename the preview test to what it actually checks and add the submit-side assertion it claimed, export the new util from the barrel, and populate the recipe-quality dimensions. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 3 + .../src/services/RewardsIntegrationService.ts | 27 ++- .../src/services/TradingService.ts | 172 ++++++++++++++++-- packages/perps-controller/src/utils/index.ts | 1 + .../src/PerpsController.operations.test.ts | 9 +- .../RewardsIntegrationService.test.ts | 43 ++++- .../tests/src/services/TradingService.test.ts | 129 +++++++++++++ 7 files changed, 361 insertions(+), 23 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ac01a678103..be6c72b42fc 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. - Add `SubscriptionController:getPerpsBenefits` and `SubscriptionController:registerAddress` to `PerpsControllerAllowedActions`, so benefits hydration and trading-address registration can run over the messenger. Clients that do not register these actions keep using the injected `subscription` dependency. - Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. +- Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID. ### Changed @@ -29,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. +- Attempt `SubscriptionController:registerAddress` whether or not the optional `subscription` dependency is injected. Registration was previously gated on that dependency, so a client that wired the messenger actions instead of the dependency silently registered nothing. A registration that no handler answers is no longer recorded as sent, so a `SubscriptionController` registered after the first fee preview still receives the address. - Normalize Lighter order timestamps from seconds to milliseconds for client date displays. ([#10187](https://github.com/MetaMask/core/pull/10187)) - Accept omitted Lighter fill PnL only when the account's validated pre-trade position is zero; retain strict PnL validation for existing positions and malformed supplied values. ([#10187](https://github.com/MetaMask/core/pull/10187)) diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 24c55db47ba..a64bc8a7820 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -478,14 +478,17 @@ export class RewardsIntegrationService { * never throws: it is observability plumbing, and a failure here must not * block a fee preview. * + * Deliberately not gated on the injected `subscription` dependency: the + * messenger action exists precisely so a client can ship + * `SubscriptionController` *instead of* the DI callback, and requiring both + * would make registration unreachable on exactly that configuration. A client + * that registers neither lands in the catch below, which is already the + * unregistered-action path. + * * @param address - The EVM trading address to register. * @returns A promise that resolves once the attempt settles. */ async registerTradingAddress(address: string): Promise { - if (!this.#deps.subscription) { - return; - } - try { const networkState = this.#messenger.call('NetworkController:getState'); const chainId = this.#getChainIdForNetwork( @@ -512,10 +515,24 @@ export class RewardsIntegrationService { return; } - await this.#messenger.call( + const pending = this.#messenger.call( 'SubscriptionController:registerAddress', caipAccountId, ); + + // An unregistered action can answer `undefined` rather than throwing. + // Treat that as "nothing handled it" and leave the dedupe cache alone, so + // a SubscriptionController registered later still receives the address + // instead of being skipped as already-registered. + if (pending === undefined) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Trading address registration skipped', + { address, reason: 'no-handler' }, + ); + return; + } + + await pending; this.#registeredTradingAddresses.add(caipAccountId); this.#deps.debugLogger.log( diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 52b8045e1e6..1f8584cbf3f 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -600,7 +600,9 @@ export class TradingService { }); // Calculate fee discount at execution time (fresh, secure) - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + this.#resolveOrderNotionalUsd(params), + ); this.#deps.debugLogger.log('TradingService: Fee resolution calculated', { feeDiscountBips: feeResolution?.discountBips, @@ -1170,9 +1172,125 @@ export class TradingService { * * @returns The result of the operation. */ - async #calculateFeeDiscountWithMeasurement(): Promise< - PerpsFeeResolution | undefined - > { + /** + * Resolve the total USD notional a batch close will submit. + * + * HyperLiquid takes one builder context for the whole batch, so the fee is + * resolved once against everything it closes rather than per position. + * Positions are read through the service context; when that read is + * unavailable or empty the notional is undefined, which the resolver reads as + * "no notional to blend against". + * + * @param options - The configuration options. + * @param options.params - Which positions the batch will close. + * @param options.context - The service context, for the positions read. + * @returns The summed notional in USD, or undefined when it cannot be read. + */ + async #resolveBatchCloseNotionalUsd(options: { + params: ClosePositionsParams; + context: ServiceContext; + }): Promise { + const { params, context } = options; + + if (!context.getPositions) { + return undefined; + } + + try { + const positions = await context.getPositions(); + // `closeAll`, or an omitted/empty symbol list, means every position. + const selected = + params.symbols && params.symbols.length > 0 + ? positions.filter((position) => + params.symbols?.includes(position.symbol), + ) + : positions; + + const total = selected.reduce((sum, position) => { + const value = Math.abs(Number.parseFloat(position.positionValue)); + return Number.isFinite(value) ? sum + value : sum; + }, 0); + + return total > 0 ? total : undefined; + } catch (error) { + // Pricing the batch must never fail the close. Without a notional the + // resolver quotes the full waiver, which is what it did before. + this.#deps.debugLogger.log( + 'TradingService: Could not price batch close for the fee resolver', + { + error: ensureError( + error, + 'TradingService.resolveBatchCloseNotionalUsd', + ).message, + }, + ); + return undefined; + } + } + + /** + * Resolve an order's USD notional for the fee resolver. + * + * `usdAmount` is the hybrid model's source of truth and is preferred whenever + * the caller supplied it. Otherwise the notional is `size × price`, taking the + * best price available: an explicit limit price, then the caller's price + * snapshot, then the live market price it was quoted against. + * + * Returns undefined when no price is available rather than guessing. The + * resolver reads that as "no notional to blend against" and quotes the full + * waiver rate — the same answer it gave before the notional was threaded + * through, so an unpriceable order is no worse off than it was. + * + * @param params - The order-shaped parameters in scope at the call site. + * @param params.size - Order size in base units, when known. + * @param params.usdAmount - Order notional in USD, when the caller supplied it. + * @param params.price - Limit price, when the placement carries one. + * @param params.currentPrice - Live market price the order was quoted against. + * @param params.priceAtCalculation - Price snapshot taken when size was derived. + * @returns The order notional in USD, or undefined when it cannot be priced. + */ + #resolveOrderNotionalUsd(params: { + size?: string; + usdAmount?: string; + price?: string; + currentPrice?: number; + priceAtCalculation?: number; + }): number | undefined { + const usdAmount = + params.usdAmount === undefined + ? undefined + : Number.parseFloat(params.usdAmount); + if ( + usdAmount !== undefined && + Number.isFinite(usdAmount) && + usdAmount > 0 + ) { + return usdAmount; + } + + const size = + params.size === undefined ? undefined : Number.parseFloat(params.size); + if (size === undefined || !Number.isFinite(size) || size <= 0) { + return undefined; + } + + const limitPrice = + params.price === undefined ? undefined : Number.parseFloat(params.price); + const price = [limitPrice, params.priceAtCalculation, params.currentPrice] + .filter( + (candidate): candidate is number => + candidate !== undefined && + Number.isFinite(candidate) && + candidate > 0, + ) + .at(0); + + return price === undefined ? undefined : size * price; + } + + async #calculateFeeDiscountWithMeasurement( + orderNotionalUsd?: number, + ): Promise { // Check if controller dependencies are available if (!this.#controllerDeps) { this.#deps.debugLogger.log( @@ -1185,8 +1303,11 @@ export class TradingService { const orderExecutionFeeDiscountStartTime = this.#deps.performance.now(); - // Calculate fee discount using messenger pattern (service handles controller access internally) - const resolution = await rewardsIntegrationService.resolveFee(); + // The notional is what lets the subscription source resolve to a blended + // rate. Submitting without it would resolve every bounded allowance as a + // full waiver, charging 0 bips on an order the preview quoted a blend for. + const resolution = + await rewardsIntegrationService.resolveFee(orderNotionalUsd); const orderExecutionFeeDiscountDuration = this.#deps.performance.now() - orderExecutionFeeDiscountStartTime; @@ -1203,6 +1324,8 @@ export class TradingService { { discountBips: resolution.discountBips, source: resolution.source, + orderNotionalUsd, + subscriptionWaiverKind: resolution.subscriptionWaiverKind, duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, }, ); @@ -1251,7 +1374,9 @@ export class TradingService { }); // Calculate fee discount only if required dependencies are available - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + this.#resolveOrderNotionalUsd(params.newOrder), + ); // Execute order edit with fee discount management const result = await this.#withFeeDiscount({ @@ -1768,7 +1893,9 @@ export class TradingService { }); // Calculate fee discount with measurement - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + this.#resolveOrderNotionalUsd(params), + ); // Execute position close with fee discount management result = await this.#withFeeDiscount({ @@ -1919,7 +2046,11 @@ export class TradingService { // Use batch close if provider supports it (provider handles filtering) if (provider.closePositions) { - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + // The batch submits under one builder context, so its notional is the + // sum of the positions it will close, not any single one of them. + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + await this.#resolveBatchCloseNotionalUsd({ params, context }), + ); operationResult = await this.#withFeeDiscount({ provider, @@ -2127,8 +2258,17 @@ export class TradingService { ...this.#buildAttributionProperties(params.trackingData), }); - // Get fee discount from rewards - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + // Get fee discount from rewards. A TP/SL update carries no notional of + // its own, so it is priced from the position the triggers protect: the + // caller's position snapshot when there is one, else the size and entry + // price the tracking data carries. + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + this.#resolveOrderNotionalUsd({ + usdAmount: params.position?.positionValue, + size: params.trackingData?.positionSize?.toString(), + currentPrice: params.trackingData?.entryPrice, + }), + ); // Execute with fee discount management result = await this.#withFeeDiscount({ @@ -2436,7 +2576,15 @@ export class TradingService { ...this.#buildAttributionProperties(trackingData), }); - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + // The flip order is 2x the position: one leg closes it, one opens the + // opposite. `orderParams` deliberately carries no price, so the notional + // comes from the position's own reported USD value. + const flipNotionalUsd = this.#resolveOrderNotionalUsd({ + usdAmount: position.positionValue, + }); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + flipNotionalUsd === undefined ? undefined : flipNotionalUsd * 2, + ); // Place flip order (HyperLiquid handles margin transfer automatically) const result = await this.#withFeeDiscount({ provider, diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts index c1104121ab8..da7cb3dcbcc 100644 --- a/packages/perps-controller/src/utils/index.ts +++ b/packages/perps-controller/src/utils/index.ts @@ -38,6 +38,7 @@ export * from './significantFigures.js'; export * from './sortMarkets.js'; export * from './standaloneInfoClient.js'; export * from './stringParseUtils.js'; +export * from './subscriptionFeeWaiver.js'; export * from './transferData.js'; export * from './wait.js'; diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index e8693864973..fde108fa28e 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1659,7 +1659,7 @@ describe('PerpsController', () => { refresh.mockRestore(); }); - it('quotes the same blended rate the submit path charges', async () => { + it('resolves the preview fee against the order notional', async () => { const feeParams = { orderType: 'market' as const, isMaker: false, @@ -1694,8 +1694,11 @@ describe('PerpsController', () => { await controller.calculateFees(feeParams); - // The preview resolves against this quote's own notional, so the rate it - // quotes is the rate the order is charged. + // Preview-side only: this asserts the notional reaches the resolver and + // the resolution reaches the preview context. The matching submit-path + // assertion lives in TradingService.test.ts ('charges a partial blend at + // submit when the allowance is bounded'), which is what makes the two + // paths verifiably agree. expect(resolveFee).toHaveBeenCalledWith(1000); const { context } = ( mockMarketDataServiceInstance.calculateFees as jest.Mock diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 2658f128da8..d54746c8399 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -993,6 +993,23 @@ describe('RewardsIntegrationService', () => { expect(registerAddress).toHaveBeenCalledTimes(2); }); + it('registers the trading address for a client that wires only the messenger', async () => { + // The ADR-0064 configuration: SubscriptionController is registered and the + // legacy DI callback is not. Gating registration on the DI dependency + // would make it a silent no-op on exactly this client. + const registerAddress = jest.fn().mockResolvedValue(undefined); + setupMessengerDefaults({ + 'SubscriptionController:registerAddress': registerAddress, + }); + expect(mockDeps.subscription).toBeUndefined(); + + await service.registerTradingAddress(mockEvmAccount.address); + + expect(registerAddress).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:1:0x/u), + ); + }); + it('never throws when address registration is unavailable', async () => { setupMessengerDefaults({ 'SubscriptionController:registerAddress': () => { @@ -1006,15 +1023,35 @@ describe('RewardsIntegrationService', () => { ).resolves.toBeUndefined(); }); - it('skips address registration entirely without a subscription source', async () => { + it('skips address registration when neither the messenger nor a source is wired', async () => { + // No SubscriptionController action registered and no DI source: the + // messenger call throws on the unregistered action and the catch absorbs + // it, so nothing is registered and nothing is raised. + setupMessengerDefaults(); + + await expect( + service.registerTradingAddress(mockEvmAccount.address), + ).resolves.toBeUndefined(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Trading address registration skipped', + expect.objectContaining({ address: mockEvmAccount.address }), + ); + }); + + it('re-attempts registration once a SubscriptionController appears', async () => { + // Nothing handled the first attempt, so it must not be cached as done — + // otherwise a client that registers the action after the first preview + // never announces its address. + setupMessengerDefaults(); + await service.registerTradingAddress(mockEvmAccount.address); + const registerAddress = jest.fn().mockResolvedValue(undefined); setupMessengerDefaults({ 'SubscriptionController:registerAddress': registerAddress, }); - await service.registerTradingAddress(mockEvmAccount.address); - expect(registerAddress).not.toHaveBeenCalled(); + expect(registerAddress).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index dbdf3adfc60..e12d6dbc2c1 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -17,6 +17,7 @@ import type { PerpsPlatformDependencies, PerpsFeeResolution, } from '../../../src/types/index.js'; +import { resolveSubscriptionWaiverRate } from '../../../src/utils/subscriptionFeeWaiver.js'; /* eslint-disable */ import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; import { @@ -141,6 +142,134 @@ describe('TradingService', () => { ); }); + it('resolves the submit fee against the order notional, not a bare rate', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'limit', + price: '50000', + }; + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // 0.02 BTC at 50000 = 1000 USD. Without this the resolver would take its + // "no notional to blend against" branch and charge a full waiver. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 1000, + ); + }); + + it('prefers the caller-supplied USD amount over size times price', async () => { + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'limit', + price: '50000', + // usdAmount is the hybrid model's source of truth; the provider + // recalculates size from it, so the fee must follow the same number. + usdAmount: '900', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 900, + ); + }); + + it('charges a partial blend at submit when the allowance is bounded', async () => { + // The real resolver, so this proves the submit path produces the same + // blend the preview quotes rather than re-asserting a mock. + mockRewardsIntegrationService.resolveFee.mockImplementation( + async (orderNotionalUsd?: number) => { + const waiver = resolveSubscriptionWaiverRate({ + status: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 250, + }, + maxFeeBips: 10, + orderNotionalUsd, + }); + return { + feeBips: waiver.feeBips, + discountBips: Math.round((1 - waiver.feeBips / 10) * 10000), + source: 'subscription' as const, + subscription: { + eligible: true, + reason: 'eligible' as const, + remainingNotionalUsd: 250, + }, + subscriptionWaiverKind: + waiver.kind === 'partial' + ? ('partial' as const) + : ('full' as const), + subscriptionCoveredNotionalUsd: waiver.coveredNotionalUsd, + }; + }, + ); + mockProvider.setUserFeeResolution = jest.fn(); + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'limit', + price: '50000', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // A 250 USD allowance against a 1000 USD order: 10 * (1 - 250/1000) = 7.5 + // bips, the same rate calculateFees quotes for this order. + expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( + expect.objectContaining({ + feeBips: 7.5, + subscriptionWaiverKind: 'partial', + subscriptionCoveredNotionalUsd: 250, + }), + ); + }); + + it('still resolves a fee when the order cannot be priced', async () => { + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // No price anywhere, so the notional is undefined rather than guessed — + // the resolver's pre-existing "no notional" behavior. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + undefined, + ); + }); + it('isolates fee resolutions between concurrent orders', async () => { const subscriptionResolution: PerpsFeeResolution = { feeBips: 0, From bf96a731f296e41735388f2268af80ed87925d71 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 17 Sep 2026 17:20:11 +0800 Subject: [PATCH 03/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the cloid marking introduced by this branch. A caller-supplied OrderParams.clientOrderId was rewritten in place when the subscription source won, so the venue received an id the caller never chose: 0xdeadbeefcafebabe0011223344556677 was submitted as 0xdeadbeef01febabe0011223344556677, and a short id was discarded outright. That id is the caller's reconciliation and idempotency key, which is a correctness contract, while attribution is observability — so only ids this package generates are re-stamped now, and anything else is returned untouched and goes unattributed. A nearly-spent allowance blends to just under the full fee, so it still won the lowest-wins comparison while its discount rounded to zero and the builder fee floored to the full rate. The order was charged full price and stamped fee_reduction_applied. Marking now follows the charged fee rather than the winning source. hasFeeReductionAppliedFlag read the flag byte with no marker check. The byte held random group entropy in Scale ladders placed before this change, so 51% of 2000 synthetic historical rungs decoded as fee-waived. The subscription program marker is the one prefix no released client ever emitted, so the flag is only trusted behind it; measured 0% after. The consequence is that a marked Scale rung now reads as unwaived, since it keeps its own group marker to preserve recovery and cancel-by-cloid — recorded in the exported JSDoc and the changelog, and asserted in the Scale tests. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 5 +- .../src/providers/HyperLiquidProvider.ts | 17 +++- .../src/utils/subscriptionFeeWaiver.ts | 75 +++++++++++++++-- .../HyperLiquidProvider.builder-fees.test.ts | 60 ++++++++++++++ ...yperLiquidProvider.strategy-orders.test.ts | 19 ++++- .../src/utils/subscriptionFeeWaiver.test.ts | 83 ++++++++++++++++++- 6 files changed, 241 insertions(+), 18 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index be6c72b42fc..2caaab49693 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -19,7 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `PerpsController.calculateFees` now quotes the subscription fee waiver as a blended rate derived from the order notional, so `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from previous releases when a subscription waiver applies. - Pass the order notional (USD) as `FeeCalculationParams.amount` to receive the rate the order will actually be charged. Omitting it quotes the full-waiver rate, matching the previous behavior. - Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. -- Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins. Any other fee source leaves the client order ID untouched. Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. +- Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins and actually reduced the fee. Any other fee source leaves the client order ID untouched, as does a subscription waiver whose remaining allowance is too small to change the charged fee. + - A client order ID supplied by the caller through `OrderParams.clientOrderId` is never rewritten, so such orders are submitted exactly as requested and are not attributed to the subscription program. Only client order IDs this package generates carry the marking. + - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. - Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) - Bump `uuid` from `^9.0.1` to `^11.1.1` ([#10243](https://github.com/MetaMask/core/pull/10243)) @@ -30,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. - Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. - Attempt `SubscriptionController:registerAddress` whether or not the optional `subscription` dependency is injected. Registration was previously gated on that dependency, so a client that wired the messenger actions instead of the dependency silently registered nothing. A registration that no handler answers is no longer recorded as sent, so a `SubscriptionController` registered after the first fee preview still receives the address. - Normalize Lighter order timestamps from seconds to milliseconds for client date displays. ([#10187](https://github.com/MetaMask/core/pull/10187)) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 6544e6890e6..21ac97e871d 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -8593,12 +8593,23 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Whether the subscription source won the fee for the operation in flight. + * Whether the operation in flight actually carries a subscription reduction. * - * @returns True when the resolved source is `subscription`. + * Winning the comparison is not sufficient. A nearly-spent allowance produces + * a blend that approaches the full fee without reaching it, so it still wins + * on `<=` — but its discount rounds to zero bips and + * {@link #getDiscountedBuilderFee} then charges the undiscounted fee. Marking + * such an order would tell the fill fan-out a waiver applied when the user + * paid full price. The marking therefore follows the charged fee, not the + * winning source. + * + * @returns True when the resolved source is `subscription` and it reduced the fee. */ #isSubscriptionFeeSource(): boolean { - return this.#userFeeResolution?.source === 'subscription'; + return ( + this.#userFeeResolution?.source === 'subscription' && + this.#getDiscountedBuilderFee() < BUILDER_FEE_CONFIG.MaxFeeTenthsBps + ); } /** diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 5f5c7b3deb8..b8ad8dcfb81 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -14,6 +14,7 @@ import type { PerpsFeeResolution, PerpsSubscriptionFeeWaiverStatus, } from '../types/index.js'; +import { HYPERLIQUID_SCALE_CLOID_MARKER } from './hyperLiquidAdapter.js'; /** * How much of an order the subscription allowance covered. @@ -150,12 +151,35 @@ export function readSubscriptionCloidFlags( * definition of "marked": the program marker alone does not mean a reduction * was charged. * + * **The flag byte is only trusted behind the subscription program marker.** An + * arbitrary cloid has no reserved flag byte, so reading one out of it is + * meaningless — and a Scale ladder placed before this release is worse than + * meaningless: `createScaleOrderIdentity` only began zeroing that byte in this + * change, so historical rungs carry random entropy there and roughly half of + * them decode as waived if the byte is read on the Scale marker alone. The + * program marker is the one prefix no previously released client ever emitted, + * which is what makes this safe to point at historical fills. + * + * The consequence is that a **marked Scale rung reads as unwaived here**: it + * keeps its Scale marker so group recovery and cancel-by-cloid keep working, and + * therefore cannot be told apart from a legacy rung by its bytes alone. A + * decoder that needs Scale attribution has to correlate on something other than + * the cloid, or wait for a ladder layout that carries a version. + * + * Note also that {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId} is still a + * placeholder pending the cloid registry value, so a decoder built on this must + * be re-pointed when the real id lands. + * * @param clientOrderId - A venue client order ID, or nothing. - * @returns True when the `fee_reduction_applied` flag is set. + * @returns True when the `fee_reduction_applied` flag is set behind the + * subscription program marker. */ export function hasFeeReductionAppliedFlag( clientOrderId: string | null | undefined, ): boolean { + if (!isSubscriptionProgramCloid(clientOrderId)) { + return false; + } const flags = readSubscriptionCloidFlags(clientOrderId); return ( flags !== undefined && @@ -201,6 +225,28 @@ export function isSubscriptionProgramCloid( ); } +/** + * Whether a client order ID is one this package generated and may re-stamp. + * + * Only the Scale ladder's own ids qualify. A caller-supplied + * `OrderParams.clientOrderId` is the caller's reconciliation key — rewriting a + * byte of it would submit an id they never chose and can no longer match a fill + * against — so it is never eligible, and neither is anything malformed. + * + * @param clientOrderId - A venue client order ID, or nothing. + * @returns True when the id was generated by this package. + */ +function isMarkableGeneratedCloid( + clientOrderId: string | null | undefined, +): boolean { + const normalized = clientOrderId?.toLowerCase(); + return Boolean( + normalized?.length === CLOID_HEX_LENGTH && + (normalized.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`) || + normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`)), + ); +} + /** * Stamp the subscription marking onto a venue client order ID. * @@ -212,23 +258,29 @@ export function isSubscriptionProgramCloid( * * The flag byte sits *after* the leading marker rather than replacing it, which * is what lets the marking compose with the cloid the Scale ladder already - * builds. Two cases: + * builds. Three cases: * * - **No existing cloid** — the leading bytes become * {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId} and the rest is fresh entropy. - * - **An existing cloid** (a Scale rung) — its own leading marker and its - * trailing bytes are preserved, and only the flag byte is set. The Scale + * - **An id this package generated** (a Scale rung) — its own leading marker and + * its trailing bytes are preserved, and only the flag byte is set. The Scale * group marker still prefixes the id, so group recovery from open orders and * cancel-by-cloid keep working, and the rung index in the last byte still * keeps every rung unique. + * - **A caller-supplied `OrderParams.clientOrderId`** — returned untouched, and + * therefore unattributed. That id is the caller's own reconciliation, + * idempotency, and telemetry key: submitting a byte-altered version would hand + * the venue an id the caller never chose and cannot match a fill against. + * Losing attribution on those orders is the strictly better trade, since + * attribution is observability while the id is a correctness contract. * * A cloid is only ever marked when the subscription source actually won, so any * other fee source leaves the id exactly as the caller built it. * * @param params - The marking inputs. - * @param params.clientOrderId - The cloid the caller already chose, if any. + * @param params.clientOrderId - The cloid already chosen for this order, if any. * @param params.entropy - Hex entropy used when there is no existing cloid. - * @returns The marked cloid. + * @returns The marked cloid, or the caller's own id unchanged. */ export function markSubscriptionCloid(params: { clientOrderId?: string; @@ -240,9 +292,16 @@ export function markSubscriptionCloid(params: { ).padStart(2, '0'); let body: string; - if (clientOrderId?.length === CLOID_HEX_LENGTH) { + if (clientOrderId !== undefined && !isMarkableGeneratedCloid(clientOrderId)) { + // A caller's id, or something malformed. Either way it is not ours to + // rewrite: hand it back exactly as supplied and forgo attribution. + if (!isHexString(clientOrderId)) { + throw new Error('Client order ID is not a hex string'); + } + return clientOrderId as Hex; + } else if (clientOrderId?.length === CLOID_HEX_LENGTH) { const existing = clientOrderId.slice(2).toLowerCase(); - // Keep the caller's marker and trailing bytes; claim only the flag byte. + // Keep the generated marker and trailing bytes; claim only the flag byte. body = `${existing.slice(0, SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength)}${flags}${existing.slice( SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength + 2, )}`; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index 5dcbf4f0b5c..f3230ee2ac2 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -1981,6 +1981,66 @@ describe('HyperLiquidProvider', () => { }); }); + it('leaves the cloid unmarked when a near-exhausted allowance reduced nothing', async () => { + // A nearly-spent allowance blends to just under the full fee, so it still + // wins the lowest-wins comparison — but its discount rounds to 0 bips and + // the builder fee floors to the full rate. Marking it would tell the fill + // fan-out a waiver applied on an order charged full price. + const exchangeClient = mockClientService.getExchangeClient(); + provider.setUserFeeResolution({ + feeBips: 9.999999, + discountBips: 0, + source: 'subscription', + subscription: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 0.01, + }, + subscriptionWaiverKind: 'partial', + subscriptionCoveredNotionalUsd: 0.01, + }); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + // The user is charged the undiscounted fee... + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: expect.objectContaining({ + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }), + }), + ); + // ...so the order must not claim a reduction. + submittedOrders(exchangeClient as never).forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(false); + expect(isSubscriptionProgramCloid(order.c)).toBe(false); + }); + }); + + it('still marks the cloid when a partial blend genuinely reduces the fee', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + provider.setUserFeeResolution({ + feeBips: 7.5, + discountBips: 2500, + source: 'subscription', + subscription: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 250, + }, + subscriptionWaiverKind: 'partial', + subscriptionCoveredNotionalUsd: 250, + }); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + submittedOrders(exchangeClient as never).forEach((order) => { + expect(hasFeeReductionAppliedFlag(order.c)).toBe(true); + }); + }); + it('marks the cloid on a TP/SL placement path', async () => { const exchangeClient = mockClientService.getExchangeClient(); provider.setUserFeeResolution(subscriptionResolution); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index a6cf1f7f432..e3d89fe6c82 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -2,6 +2,7 @@ import { HyperliquidError } from '@nktkas/hyperliquid'; import type { MetaResponse } from '@nktkas/hyperliquid'; import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; +import { SUBSCRIPTION_CLOID_FLAGS } from '../../../src/constants/perpsConfig.js'; import { CHASE_ORDER_CONFIG, CHASE_ORDER_STATUS, @@ -34,7 +35,10 @@ import { validateOrderParams, validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; -import { hasFeeReductionAppliedFlag } from '../../../src/utils/subscriptionFeeWaiver.js'; +import { + hasFeeReductionAppliedFlag, + readSubscriptionCloidFlags, +} from '../../../src/utils/subscriptionFeeWaiver.js'; import { createMockPosition } from '../../helpers/providerMocks.js'; import { createDeferred, @@ -3466,14 +3470,20 @@ describe('HyperLiquidProvider - strategy order types', () => { const submitted = exchangeClient.order.mock.calls[0][0]; const cloids = submitted.orders.map((order: { c?: string }) => order.c); - // Every rung carries the attribution... + // Every rung carries the attribution in its flag byte... cloids.forEach((cloid: string) => { - expect(hasFeeReductionAppliedFlag(cloid)).toBe(true); + expect(readSubscriptionCloidFlags(cloid)).toBe( + SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied, + ); // ...while keeping the Scale marker, so the group stays recoverable // from open orders and cancel-by-cloid keeps working. expect(cloid.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`)).toBe( true, ); + // A decoder still will not trust that byte behind the Scale marker, + // since legacy ladders carry random entropy there. Scale attribution + // needs a correlation other than the cloid. + expect(hasFeeReductionAppliedFlag(cloid)).toBe(false); }); // And each rung is still a distinct id. expect(new Set(cloids).size).toBe(3); @@ -3494,7 +3504,8 @@ describe('HyperLiquidProvider - strategy order types', () => { const submitted = exchangeClient.order.mock.calls[0][0]; submitted.orders.forEach((order: { c?: string }) => { - expect(hasFeeReductionAppliedFlag(order.c)).toBe(false); + // The reserved flag byte stays zero when subscription did not win. + expect(readSubscriptionCloidFlags(order.c)).toBe(0); expect(order.c?.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`)).toBe( true, ); diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index a06cf4b65d5..0f143886e93 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -173,9 +173,49 @@ describe('markSubscriptionCloid', () => { expect(marked.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`)).toBe(true); // The rung index survives, so rungs cannot collide on one cloid. expect(marked.slice(-2)).toBe('07'); - // And the attribution is carried in the flag byte instead. - expect(hasFeeReductionAppliedFlag(marked)).toBe(true); + // The attribution is carried in the flag byte instead... + expect(readSubscriptionCloidFlags(marked)).toBe( + SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied, + ); expect(isSubscriptionProgramCloid(marked)).toBe(false); + // ...but the decoder will not trust a flag byte behind the Scale marker, + // because legacy ladders carry random entropy in that position. A marked + // rung is therefore indistinguishable from a legacy one to a decoder. + expect(hasFeeReductionAppliedFlag(marked)).toBe(false); + }); + + it('returns a caller-supplied client order ID untouched', () => { + // OrderParams.clientOrderId is public API and the caller's own + // reconciliation key. Rewriting a byte of it would submit an id they never + // chose and cannot match a fill against, so attribution is forgone instead. + const caller = '0xdeadbeefcafebabe0011223344556677' as const; + + const result = markSubscriptionCloid({ + clientOrderId: caller, + entropy: 'b'.repeat(32), + }); + + expect(result).toBe(caller); + expect(hasFeeReductionAppliedFlag(result)).toBe(false); + }); + + it('returns a short caller client order ID untouched rather than replacing it', () => { + // A cloid the venue may still accept but this package did not generate. + // Discarding it outright would be the worst outcome of all. + const caller = '0x1234' as const; + + expect( + markSubscriptionCloid({ clientOrderId: caller, entropy: 'b'.repeat(32) }), + ).toBe(caller); + }); + + it('rejects a caller client order ID that is not hex', () => { + expect(() => + markSubscriptionCloid({ + clientOrderId: 'not-a-cloid', + entropy: 'b'.repeat(32), + }), + ).toThrow('Client order ID is not a hex string'); }); it('keeps every marked rung of one ladder distinct', () => { @@ -201,6 +241,45 @@ describe('hasFeeReductionAppliedFlag', () => { }, ); + it('does not trust the flag byte outside the subscription program marker', () => { + // Scale ladders placed before this release carry random entropy where the + // flag byte now lives, so roughly half of them would decode as waived if the + // byte were read on the Scale marker alone. An arbitrary caller cloid has no + // reserved flag byte at all. + const legacyLadderWithFlagBitSet = `0x${HYPERLIQUID_SCALE_CLOID_MARKER}ab${'cd'.repeat(10)}07`; + const callerCloidWithFlagBitSet = '0xdeadbeef01febabe0011223344556677'; + + // The flag byte itself reads as set in both... + expect(readSubscriptionCloidFlags(legacyLadderWithFlagBitSet)).toBe(0xab); + expect(readSubscriptionCloidFlags(callerCloidWithFlagBitSet)).toBe(0x01); + + // ...but neither carries the program marker, so neither is trusted. + expect(hasFeeReductionAppliedFlag(legacyLadderWithFlagBitSet)).toBe(false); + expect(hasFeeReductionAppliedFlag(callerCloidWithFlagBitSet)).toBe(false); + }); + + it('reports no false positives across a legacy Scale ladder population', () => { + // The defect this guards: before this change the byte the flag occupies held + // random group entropy, so ~50% of historical rungs set bit 0. + // Pre-change layout: marker (4 bytes) + 11 bytes of random group entropy + + // the rung index byte. The first entropy byte is where the flag now lives. + const legacyRungs = Array.from({ length: 256 }, (_, index) => { + const group = index.toString(16).padStart(2, '0').repeat(11); + return `0x${HYPERLIQUID_SCALE_CLOID_MARKER}${group}07`; + }); + + // Every fixture is a well-formed cloid, and many do set the flag bit... + expect(legacyRungs.every((rung) => rung.length === 34)).toBe(true); + expect( + legacyRungs.filter( + (rung) => (readSubscriptionCloidFlags(rung) ?? 0) % 2 === 1, + ).length, + ).toBeGreaterThan(0); + + // ...yet none decodes as a subscription waiver. + expect(legacyRungs.filter(hasFeeReductionAppliedFlag)).toStrictEqual([]); + }); + it('reports no flag for an unmarked Scale cloid, whose flag byte is reserved', () => { // The Scale generator zeroes the byte the subscription flag lives in, so // an unmarked ladder can never decode downstream as a waived one. From 94d285ecf4208df6ecdb5d02b66055a2e63ff458 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 17 Sep 2026 23:43:52 +0800 Subject: [PATCH 04/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, two of them in paths this branch claimed to have already covered. Benefits hydration was unreachable over the messenger. Both the eligibility read and the refresh returned early on the injected subscription dependency, before SubscriptionController:getPerpsBenefits could run, so a client that adopts the controller action without retaining the legacy callback always resolved no-source. The previous round removed that guard from address registration but left it on hydration, which is the path the ADR actually targets. A source predicate now accepts either wiring, and the refresh attempts the messenger regardless so a delegated registration — invisible to getRegisteredActionTypes — can prove itself on first call. A full position close priced its fee from the close parameters, which commonly carry only a symbol, so the notional was undefined and the resolver quoted a full waiver on an order the preview had blended. The authoritative position is loaded a few lines earlier; it now supplies the notional, and a partial close is priced from the position's value per unit. Provenance of a client order ID was inferred from its leading marker, so a caller-supplied cloid beginning with a reserved prefix had its flag byte rewritten — the exact contract the previous round introduced. A prefix cannot prove authorship, so the marking now takes an explicit isGenerated flag and the provider declares the ids it just generated. Also corrects the evidence: the coverage table marked submit agreement and the SubscriptionController integration proven without exercising the failing paths, and claimed more attribution than Scale ladders can deliver downstream. Four assertions added, the two deliberate marking exclusions documented, and the weak count corrected from 0 to 1. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 4 +- .../src/providers/HyperLiquidProvider.ts | 18 ++++- .../src/services/RewardsIntegrationService.ts | 77 ++++++++++++++----- .../src/services/TradingService.ts | 39 +++++++++- .../src/utils/subscriptionFeeWaiver.ts | 50 +++++------- .../RewardsIntegrationService.test.ts | 45 +++++++++++ .../tests/src/services/TradingService.test.ts | 53 +++++++++++++ .../src/utils/subscriptionFeeWaiver.test.ts | 46 +++++++++++ 8 files changed, 274 insertions(+), 58 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2caaab49693..e33b7e9bc91 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pass the order notional (USD) as `FeeCalculationParams.amount` to receive the rate the order will actually be charged. Omitting it quotes the full-waiver rate, matching the previous behavior. - Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. - Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins and actually reduced the fee. Any other fee source leaves the client order ID untouched, as does a subscription waiver whose remaining allowance is too small to change the charged fee. - - A client order ID supplied by the caller through `OrderParams.clientOrderId` is never rewritten, so such orders are submitted exactly as requested and are not attributed to the subscription program. Only client order IDs this package generates carry the marking. + - A client order ID supplied by the caller through `OrderParams.clientOrderId` is never rewritten, so such orders are submitted exactly as requested and are not attributed to the subscription program. Only client order IDs this package generates carry the marking, and that provenance is tracked explicitly rather than inferred from the ID's leading bytes — a caller-supplied ID beginning with a reserved marker is still preserved. - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. - Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. +- Hydrate subscription benefits for a client that registers `SubscriptionController:getPerpsBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. +- Price a position close from the loaded position when the close parameters do not carry a notional. A full close commonly passes only a symbol, which previously resolved as an unbounded waiver rather than blending against the position's value; a partial close is now priced from the position's value per unit. - Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. - Attempt `SubscriptionController:registerAddress` whether or not the optional `subscription` dependency is injected. Registration was previously gated on that dependency, so a client that wired the messenger actions instead of the dependency silently registered nothing. A registration that no handler answers is no longer recorded as sent, so a `SubscriptionController` registered after the first fee preview still receives the address. - Normalize Lighter order timestamps from seconds to milliseconds for client date displays. ([#10187](https://github.com/MetaMask/core/pull/10187)) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 21ac97e871d..4f59d567c68 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -6264,6 +6264,8 @@ export class HyperLiquidProvider implements PerpsProvider { t: { limit: { tif: 'Gtc' as const } }, c: ladderClientOrderIds[index], })), + // These ids were generated a few lines above, so they are ours to stamp. + new Set(ladderClientOrderIds), ); const clientOrderIds: Hex[] = orders.map((order, index) => order.c === undefined ? ladderClientOrderIds[index] : (order.c as Hex), @@ -8621,13 +8623,23 @@ export class HyperLiquidProvider implements PerpsProvider { * through here, so no path can silently ship an unmarked order while the * waiver is being charged, and no other fee source can produce a marked one. * - * Orders that already carry a cloid keep their trailing entropy, so the Scale + * A cloid this package generated keeps its trailing entropy, so the Scale * ladder's per-rung index and its cancel-by-cloid recovery survive marking. + * A caller's `OrderParams.clientOrderId` is never rewritten, so those orders + * go unattributed — which is why `isGenerated` is passed explicitly rather + * than inferred from the id's leading bytes: a caller is free to supply one + * that happens to begin with a reserved marker. * * @param orders - The SDK order payloads about to be submitted. + * @param generatedCloids - Cloids this package generated for these orders and + * may therefore re-stamp. Only the Scale ladder supplies any; every other path + * either has no cloid or carries the caller's own. * @returns The same payloads, with cloids marked when subscription won. */ - #applySubscriptionCloid(orders: SDKOrderParams[]): SDKOrderParams[] { + #applySubscriptionCloid( + orders: SDKOrderParams[], + generatedCloids?: ReadonlySet, + ): SDKOrderParams[] { if (!this.#isSubscriptionFeeSource()) { // Any other source leaves the id exactly as the caller built it. return orders; @@ -8637,6 +8649,8 @@ export class HyperLiquidProvider implements PerpsProvider { ...order, c: markSubscriptionCloid({ clientOrderId: order.c ?? undefined, + isGenerated: + order.c !== undefined && Boolean(generatedCloids?.has(order.c)), entropy: uuidv4().replace(/-/gu, ''), }), })); diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index a64bc8a7820..3484d93a441 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -97,6 +97,14 @@ export class RewardsIntegrationService { */ readonly #registeredTradingAddresses = new Set(); + /** + * Whether `SubscriptionController:getPerpsBenefits` has ever answered on this + * messenger. Action registration can be delegated, in which case it does not + * appear in `getRegisteredActionTypes`, so an answered call is the only + * reliable proof that the messenger route is available. + */ + #messengerBenefitsAnswered = false; + /** * Create a new RewardsIntegrationService instance * @@ -238,7 +246,7 @@ export class RewardsIntegrationService { * @returns Whether the waiver applies, why, and the remaining notional. */ getSubscriptionFeeWaiverStatus(): PerpsSubscriptionFeeWaiverStatus { - if (!this.#deps.subscription) { + if (!this.#hasSubscriptionSource()) { return { eligible: false, reason: 'no-source' }; } @@ -266,6 +274,36 @@ export class RewardsIntegrationService { return evaluateFeeWaiverGate(snapshot.benefits); } + /** + * Whether this client has any way to read subscription benefits. + * + * Either wiring counts: a registered `SubscriptionController:getPerpsBenefits` + * action (the ADR 0064 target) or the legacy injected `subscription` callback. + * Requiring the injected one would make the messenger path unreachable on + * exactly the configuration it was added for, so the messenger is probed by + * asking whether an action handler exists rather than by calling it — this + * runs on the synchronous status read and must not start work. + * + * @returns True when benefits can be read by some route. + */ + #hasSubscriptionSource(): boolean { + if (this.#deps.subscription || this.#messengerBenefitsAnswered) { + return true; + } + + try { + // `getRegisteredActionTypes` reports this messenger's own registrations. + // A delegated action does not appear there, which is why it is only a + // positive signal — `refreshSubscriptionBenefits` still attempts the call + // regardless, and an answer sets `#messengerBenefitsAnswered` above. + return this.#messenger + .getRegisteredActionTypes() + .includes('SubscriptionController:getPerpsBenefits'); + } catch { + return false; + } + } + /** * Whether the subscription fee-waiver source is enabled remotely. * @@ -299,10 +337,10 @@ export class RewardsIntegrationService { * @returns A promise that settles when the refresh completes. */ async refreshSubscriptionBenefits(): Promise { - const source = this.#deps.subscription; - if (!source) { - return; - } + // Deliberately not gated on `#hasSubscriptionSource`: a delegated action is + // invisible to `getRegisteredActionTypes`, so the first call is what proves + // it is reachable. A client with no source at all reads `null` here, which + // costs one no-op call per freshness window and leaves the gate closed. if (this.#benefitsRefresh) { await this.#benefitsRefresh; @@ -322,7 +360,7 @@ export class RewardsIntegrationService { return; } - const refresh = this.#readSubscriptionBenefits(source); + const refresh = this.#readSubscriptionBenefits(); this.#benefitsRefresh = refresh; // `finally` always defers, so this never clears the handle we just set. refresh @@ -368,16 +406,12 @@ export class RewardsIntegrationService { /** * Perform one benefits read and store it, keeping the previous snapshot on * error. Never rejects, so callers cannot produce an unhandled rejection. - * - * @param source - The injected subscription benefits source. */ - async #readSubscriptionBenefits( - source: NonNullable, - ): Promise { + async #readSubscriptionBenefits(): Promise { const epoch = this.#benefitsEpoch; try { - const benefits = await this.#getPerpsBenefits(source); + const benefits = await this.#getPerpsBenefits(); if (epoch !== this.#benefitsEpoch) { // Invalidated while this read was in flight: it belongs to a previous @@ -429,17 +463,15 @@ export class RewardsIntegrationService { /** * Read subscription benefits, preferring the messenger over the DI callback. * - * ADR 0064 moves hydration onto `SubscriptionController`. Clients that have - * not shipped it yet register no such action, and the messenger throws on an - * unregistered action name — so the injected `subscription` dependency stays - * the fallback rather than a second source of truth. + * ADR 0064 moves hydration onto `SubscriptionController`. The messenger is + * tried first and the injected `subscription` dependency is only a fallback, + * so a client that ships `SubscriptionController` without the legacy callback + * hydrates normally — that configuration is the ADR's target, not an edge + * case. A client with neither gets `null`, which reads as "no subscription". * - * @param source - The injected subscription benefits source. * @returns The benefits payload, or null when there is none to report. */ - async #getPerpsBenefits( - source: NonNullable, - ): Promise { + async #getPerpsBenefits(): Promise { let pending: Promise | undefined; try { // Called without awaiting so the fallback stays synchronous when no @@ -451,6 +483,7 @@ export class RewardsIntegrationService { // `null` is a real answer ("no subscription"); `undefined` means nothing // handled the action, which is the fallback case rather than an answer. if (result !== undefined) { + this.#messengerBenefitsAnswered = true; pending = Promise.resolve(result); } } catch { @@ -466,7 +499,9 @@ export class RewardsIntegrationService { } } - return await source.getPerpsBenefits(); + // No handler answered. Fall back to the injected source when one exists; + // otherwise there is genuinely nothing to report. + return (await this.#deps.subscription?.getPerpsBenefits()) ?? null; } /** diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 1f8584cbf3f..f9f394b2f2f 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1228,6 +1228,28 @@ export class TradingService { } } + /** + * The position's USD value per unit of size. + * + * Used to price a partial close, which names a size but usually no price. + * + * @param position - The loaded position, when one was found. + * @returns The per-unit price, or undefined when it cannot be derived. + */ + #resolvePositionUnitPrice( + position: Position | undefined, + ): number | undefined { + if (!position) { + return undefined; + } + const value = Math.abs(Number.parseFloat(position.positionValue)); + const size = Math.abs(Number.parseFloat(position.size)); + if (!Number.isFinite(value) || !Number.isFinite(size) || size <= 0) { + return undefined; + } + return value / size; + } + /** * Resolve an order's USD notional for the fee resolver. * @@ -1892,9 +1914,22 @@ export class TradingService { }), }); - // Calculate fee discount with measurement + // Calculate fee discount with measurement. A full close commonly carries + // only a symbol, so `params` alone prices it as undefined and the resolver + // would quote a full waiver on an order the preview blended. The position + // loaded above is the authoritative notional for exactly that case. const feeResolution = await this.#calculateFeeDiscountWithMeasurement( - this.#resolveOrderNotionalUsd(params), + this.#resolveOrderNotionalUsd({ + ...params, + // A partial close names a size but often no price; the position's own + // value per unit prices it. A full close names neither, and falls back + // to the whole position value below. + currentPrice: + params.currentPrice ?? this.#resolvePositionUnitPrice(position), + }) ?? + this.#resolveOrderNotionalUsd({ + usdAmount: position?.positionValue, + }), ); // Execute position close with fee discount management diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index b8ad8dcfb81..8a4fefbe18c 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -14,7 +14,6 @@ import type { PerpsFeeResolution, PerpsSubscriptionFeeWaiverStatus, } from '../types/index.js'; -import { HYPERLIQUID_SCALE_CLOID_MARKER } from './hyperLiquidAdapter.js'; /** * How much of an order the subscription allowance covered. @@ -225,28 +224,6 @@ export function isSubscriptionProgramCloid( ); } -/** - * Whether a client order ID is one this package generated and may re-stamp. - * - * Only the Scale ladder's own ids qualify. A caller-supplied - * `OrderParams.clientOrderId` is the caller's reconciliation key — rewriting a - * byte of it would submit an id they never chose and can no longer match a fill - * against — so it is never eligible, and neither is anything malformed. - * - * @param clientOrderId - A venue client order ID, or nothing. - * @returns True when the id was generated by this package. - */ -function isMarkableGeneratedCloid( - clientOrderId: string | null | undefined, -): boolean { - const normalized = clientOrderId?.toLowerCase(); - return Boolean( - normalized?.length === CLOID_HEX_LENGTH && - (normalized.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`) || - normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`)), - ); -} - /** * Stamp the subscription marking onto a venue client order ID. * @@ -262,11 +239,11 @@ function isMarkableGeneratedCloid( * * - **No existing cloid** — the leading bytes become * {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId} and the rest is fresh entropy. - * - **An id this package generated** (a Scale rung) — its own leading marker and - * its trailing bytes are preserved, and only the flag byte is set. The Scale - * group marker still prefixes the id, so group recovery from open orders and - * cancel-by-cloid keep working, and the rung index in the last byte still - * keeps every rung unique. + * - **An id this package generated**, declared by the caller through + * `isGenerated` (a Scale rung) — its own leading marker and its trailing bytes + * are preserved, and only the flag byte is set. The Scale group marker still + * prefixes the id, so group recovery from open orders and cancel-by-cloid keep + * working, and the rung index in the last byte still keeps every rung unique. * - **A caller-supplied `OrderParams.clientOrderId`** — returned untouched, and * therefore unattributed. That id is the caller's own reconciliation, * idempotency, and telemetry key: submitting a byte-altered version would hand @@ -274,27 +251,36 @@ function isMarkableGeneratedCloid( * Losing attribution on those orders is the strictly better trade, since * attribution is observability while the id is a correctness contract. * + * Provenance is declared, never inferred. A leading marker cannot prove who + * generated an id: a caller is free to supply a 16-byte cloid that happens to + * begin with the Scale or subscription marker, and guessing from the prefix + * would rewrite exactly the id the contract promises to preserve. + * * A cloid is only ever marked when the subscription source actually won, so any * other fee source leaves the id exactly as the caller built it. * * @param params - The marking inputs. * @param params.clientOrderId - The cloid already chosen for this order, if any. + * @param params.isGenerated - True only when `clientOrderId` was generated by + * this package and may therefore be re-stamped. Defaults to false, so an id of + * unknown provenance is preserved rather than rewritten. * @param params.entropy - Hex entropy used when there is no existing cloid. * @returns The marked cloid, or the caller's own id unchanged. */ export function markSubscriptionCloid(params: { clientOrderId?: string; + isGenerated?: boolean; entropy: string; }): Hex { - const { clientOrderId, entropy } = params; + const { clientOrderId, isGenerated = false, entropy } = params; const flags = SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied.toString( 16, ).padStart(2, '0'); let body: string; - if (clientOrderId !== undefined && !isMarkableGeneratedCloid(clientOrderId)) { - // A caller's id, or something malformed. Either way it is not ours to - // rewrite: hand it back exactly as supplied and forgo attribution. + if (clientOrderId !== undefined && !isGenerated) { + // Not ours to rewrite: hand it back exactly as supplied and forgo + // attribution. if (!isHexString(clientOrderId)) { throw new Error('Client order ID is not a hex string'); } diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index d54746c8399..2ad892b5c6c 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -960,6 +960,51 @@ describe('RewardsIntegrationService', () => { expect(resolution.subscriptionWaiverKind).toBe('partial'); }); + it('hydrates and grants the waiver for a messenger-only client', async () => { + // The ADR 0064 target configuration: SubscriptionController is registered + // and the legacy DI callback is not. Gating hydration on the DI callback + // made this configuration resolve `no-source` and never grant a waiver. + const messengerBenefits = jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })); + setupMessengerDefaults({ + 'SubscriptionController:getPerpsBenefits': messengerBenefits, + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + expect(mockDeps.subscription).toBeUndefined(); + + await service.refreshSubscriptionBenefits(); + + expect(messengerBenefits).toHaveBeenCalled(); + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 250, + }); + + // And the waiver actually reaches the resolution, blended by notional. + const resolution = await service.resolveFee(1000); + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBeCloseTo(7.5, 10); + expect(resolution.subscriptionWaiverKind).toBe('partial'); + }); + + it('still reports no source when neither wiring is present', async () => { + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + await service.refreshSubscriptionBenefits(); + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'no-source', + }); + }); + it('falls back to the injected benefits source when no SubscriptionController action is registered', async () => { const diBenefits = wireSubscription( jest.fn().mockResolvedValue(createBenefits()), diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index e12d6dbc2c1..a3c2ae5d71c 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1774,6 +1774,59 @@ describe('TradingService', () => { stopLossCount: 0, }; + it('prices a full close from the loaded position notional', async () => { + // A full close carries only a symbol, so `params` alone prices it as + // undefined and the resolver would quote a full waiver on an order the + // preview blended. The loaded position is the authoritative notional. + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue({ success: true }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 25000, + ); + }); + + it('prices a partial close from the position unit price', async () => { + // A partial close names a size but usually no price. 25000 USD over 0.5 + // BTC is 50000 per unit, so closing 0.1 is a 5000 USD notional. + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue({ success: true }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC', size: '0.1' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 5000, + ); + }); + + it('prefers an explicit close USD amount over the position value', async () => { + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue({ success: true }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC', size: '0.1', usdAmount: '4800' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 4800, + ); + }); + it('closes position successfully without fee discount', async () => { const params: ClosePositionParams = { symbol: 'BTC', diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index 0f143886e93..ee8be7593b9 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -164,6 +164,7 @@ describe('markSubscriptionCloid', () => { const marked = markSubscriptionCloid({ clientOrderId: rung, + isGenerated: true, entropy: 'ffffffffffffffffffffffffffffffff', }); @@ -209,6 +210,50 @@ describe('markSubscriptionCloid', () => { ).toBe(caller); }); + it('preserves a caller client order ID that begins with a reserved marker', () => { + // Provenance is declared, not inferred. A caller is free to supply a + // well-formed cloid whose leading bytes happen to match a reserved marker, + // and guessing from the prefix would rewrite exactly the id the contract + // promises to preserve. + const scalePrefixed = + `0x${HYPERLIQUID_SCALE_CLOID_MARKER}ffbbccddeeff001122334455` as const; + const programPrefixed = + `0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}ffbbccddeeff001122334455` as const; + + expect(scalePrefixed).toHaveLength(34); + expect(programPrefixed).toHaveLength(34); + + // Without an explicit `isGenerated`, both are the caller's and untouched. + expect( + markSubscriptionCloid({ + clientOrderId: scalePrefixed, + entropy: 'b'.repeat(32), + }), + ).toBe(scalePrefixed); + expect( + markSubscriptionCloid({ + clientOrderId: programPrefixed, + entropy: 'b'.repeat(32), + }), + ).toBe(programPrefixed); + }); + + it('re-stamps an id only when it is declared as package-generated', () => { + const rung = + `0x${HYPERLIQUID_SCALE_CLOID_MARKER}00${'ab'.repeat(10)}07` as const; + + const marked = markSubscriptionCloid({ + clientOrderId: rung, + isGenerated: true, + entropy: 'b'.repeat(32), + }); + + expect(marked).not.toBe(rung); + expect(readSubscriptionCloidFlags(marked)).toBe( + SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied, + ); + }); + it('rejects a caller client order ID that is not hex', () => { expect(() => markSubscriptionCloid({ @@ -225,6 +270,7 @@ describe('markSubscriptionCloid', () => { `0x${HYPERLIQUID_SCALE_CLOID_MARKER}00${'ab'.repeat(10)}${index .toString(16) .padStart(2, '0')}` as const, + isGenerated: true, entropy: 'ffffffffffffffffffffffffffffffff', }), ); From eadebd502f6bb46fc896b7b872de74978a4164cb Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 00:10:42 +0800 Subject: [PATCH 05/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the registered subscription program_id 0x0100, zero-extended big-endian into the existing 4-byte marker field so the flag-byte offset and the rest of the cloid layout are unchanged. It cannot collide with the Scale marker and no released client emitted a cloid starting with those bytes, so the decoder stays safe against historical fills. A chase replacement paid the discounted fee with an unmarked client order ID. The session already stores the builder fee it was quoted at, precisely because the fee resolution behind it is cleared when the caller's placeOrder returns — but marking still read that live resolution, so every replacement after the first went out unattributed. The marking decision is now captured on the session alongside the fee. Proven by a test that failed before the fix. A bounded allowance with no determinable order notional resolved to a full waiver, charging nothing on an order of unknown size and over-consuming the cap. It now withholds the source, matching how an exhausted or stale gate behaves. An unbounded allowance is unchanged: with no reported cap there is nothing to over-consume. Batch-close notional summed every aggregated provider's positions while the batch routes to one, inflating the notional and shrinking the waiver. Position carries no provider id, so it is now read through the provider that submits. The deprecated approval method resolves true rather than false: it answers whether the subscription builder is ready, and nothing needs approving, so false read as a setup failure. Adds an exact ./utils subpath export and exports the two SubscriptionController action types. The allowed-actions unions are deliberately not exported — the controller guidelines forbid it and lint enforces it. Two further findings ask that orders which cannot carry attribution — Scale fills and caller-supplied client order IDs — be denied the subscription rate. That means charging entitled subscribers full price to keep backend accounting clean, which is a product trade-off rather than an implementation detail; it is recorded in the task report with the structural notes a decision would need. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 11 +++- packages/perps-controller/package.json | 4 ++ .../perps-controller/src/PerpsController.ts | 13 ++-- .../src/constants/perpsConfig.ts | 11 +++- packages/perps-controller/src/index.ts | 12 ++++ .../src/providers/HyperLiquidProvider.ts | 58 +++++++++++++---- .../src/services/TradingService.ts | 19 +++--- .../src/utils/subscriptionFeeWaiver.ts | 18 ++++-- .../src/PerpsController.operations.test.ts | 5 +- ...yperLiquidProvider.strategy-orders.test.ts | 62 +++++++++++++++++++ .../RewardsIntegrationService.test.ts | 39 +++++++++--- .../src/utils/subscriptionFeeWaiver.test.ts | 22 ++++++- 12 files changed, 226 insertions(+), 48 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index e33b7e9bc91..7d23c44ad00 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. - Add `SubscriptionController:getPerpsBenefits` and `SubscriptionController:registerAddress` to `PerpsControllerAllowedActions`, so benefits hydration and trading-address registration can run over the messenger. Clients that do not register these actions keep using the injected `subscription` dependency. - Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. -- Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID. +- Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID, and add an exact `./utils` subpath export so the barrel is importable as `@metamask/perps-controller/utils`. +- Export `SubscriptionControllerGetPerpsBenefitsAction` and `SubscriptionControllerRegisterAddressAction` from the package index, so a client registering these handlers does not have to restate their shapes. ### Changed @@ -21,18 +22,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. - Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins and actually reduced the fee. Any other fee source leaves the client order ID untouched, as does a subscription waiver whose remaining allowance is too small to change the charged fee. - A client order ID supplied by the caller through `OrderParams.clientOrderId` is never rewritten, so such orders are submitted exactly as requested and are not attributed to the subscription program. Only client order IDs this package generates carry the marking, and that provenance is tracked explicitly rather than inferred from the ID's leading bytes — a caller-supplied ID beginning with a reserved marker is still preserved. - - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. + - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. Scale fills and fills on caller-supplied client order IDs therefore receive the discount without a decodable marker; attributing them needs a correlation other than the client order ID. + - The subscription program marker is the registered id `0x0100`, zero-extended into the 4-byte marker field. - Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) - Bump `uuid` from `^9.0.1` to `^11.1.1` ([#10243](https://github.com/MetaMask/core/pull/10243)) ### Deprecated -- Deprecate `PerpsController.approveSubscriptionBuilderFee`, `PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated subscription builder address configuration. Subscription attribution now rides on the order's client order ID rather than a separate approved builder, so the controller method is a no-op that always resolves `false`. The provider-side approval machinery is retained but unreachable from order construction. +- Deprecate `PerpsController.approveSubscriptionBuilderFee`, `PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated subscription builder address configuration. Subscription attribution now rides on the order's client order ID rather than a separate approved builder, so the controller method is a no-op that always resolves `true` — nothing needs approving, and a `false` would read as a setup failure to a caller that branches on it. The provider-side approval machinery is retained but unreachable from order construction. ### Fixed - Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. +- Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. +- Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. +- Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. - Hydrate subscription benefits for a client that registers `SubscriptionController:getPerpsBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. - Price a position close from the loaded position when the close parameters do not carry a notional. A full close commonly passes only a symbol, which previously resolved as an unbounded waiver rather than blending against the position's value; a partial close is now priced from the position's value per unit. - Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 04115117d1b..672e21277c1 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -37,6 +37,10 @@ "types": "./dist/types/index.d.ts", "default": "./dist/types/index.js" }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "default": "./dist/utils/index.js" + }, "./utils/*": { "types": "./dist/utils/*.d.ts", "default": "./dist/utils/*.js" diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index f6b7c570c36..3f8eb9c8041 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5831,15 +5831,20 @@ export class PerpsController extends BaseController< * * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid * marking on the standard builder, so there is nothing left to approve. Kept - * as a no-op returning `false` so clients still calling it keep building - * while they migrate; remove it once cloid marking is verified in shadow mode. - * @returns Always `false`. + * as a no-op so clients still calling it keep building while they migrate; + * remove it once cloid marking is verified in shadow mode. + * + * Resolves `true`, not `false`. The method answers "is the subscription + * builder ready?", and the honest answer is now "nothing needs approving" — + * a `false` would read as a setup failure to a caller that branches on it and + * could block a waiver that is already fully in effect. + * @returns Always `true`; no approval is required. */ async approveSubscriptionBuilderFee(): Promise { this.#debugLog( 'PerpsController: approveSubscriptionBuilderFee is a no-op; subscription attribution now rides on the order cloid', ); - return false; + return true; } /** diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 4c85ec83903..3ea091d4c60 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -441,9 +441,16 @@ export const SUBSCRIPTION_BENEFITS_CACHE = { export const SUBSCRIPTION_CLOID_CONFIG = { /** * Reserved program marker, 4 bytes as lowercase hex without the `0x`. - * PLACEHOLDER — pending the cloid registry value from ADR 0064. + * + * The registry assigns perps subscriptions program_id `0x0100`, from the + * `0x0000`–`0x00FF`-adjacent range reserved for core protocol features. It is + * a 2-byte id, zero-extended big-endian into this 4-byte marker field so the + * rest of the layout — flag byte at a fixed offset, 11 bytes of entropy — is + * unchanged. No previously released client emitted a cloid starting with + * these bytes, and it cannot collide with the Scale marker (`4d4d5343`), + * which is what makes the decoder safe against historical fills. */ - ProgramId: '4d4d5342', + ProgramId: '00000100', /** Hex characters in the leading program marker (4 bytes). */ ProgramIdHexLength: 8, diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index e97b7f34822..7d9707ff77e 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -57,6 +57,18 @@ export type { ProPositionsSortDirection, ProPositionsSortField, } from './PerpsController.js'; +// The SubscriptionController action contracts ADR 0064 introduces. A client +// registering these handlers needs their shapes, and no package defines them +// yet — `SubscriptionController` does not live in this monorepo — so they are +// exported here rather than duplicated downstream. +// +// The `PerpsControllerAllowedActions`/`AllowedEvents` unions are deliberately +// not exported: the controller guidelines forbid exporting external-dependency +// unions from a package index, and lint enforces it. +export type { + SubscriptionControllerGetPerpsBenefitsAction, + SubscriptionControllerRegisterAddressAction, +} from './types/messenger.js'; export type { PerpsControllerApproveSubscriptionBuilderFeeAction, PerpsControllerCalculateFeesAction, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 4f59d567c68..f6e0cf38218 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -999,6 +999,16 @@ type ChaseSession = { * would otherwise be re-quoted at the undiscounted maximum. */ builder?: BuilderOrderContext; + /** + * Whether this chase's orders carry the subscription cloid marking. + * + * Captured with {@link builder} and for the same reason: the fee resolution + * that decides it is live only around the caller's `placeOrder`, and a chase + * returns immediately, so every replacement runs after it is cleared. Without + * this a replacement would pay the discounted fee the session captured while + * shipping an unmarked cloid, and the fill could not be attributed. + */ + marksSubscriptionCloid?: boolean; /** Manual HIP-3 collateral retained while this session has venue exposure. */ hip3Transfer?: Hip3TransferContext; /** Coalesces concurrent terminal cleanup reads for this session. */ @@ -6541,6 +6551,10 @@ export class HyperLiquidProvider implements PerpsProvider { generation: number, ): Promise { const { assetId, szDecimals, formattedSize, builder } = context; + // Captured now, alongside the builder fee and for the same reason: the fee + // resolution behind it is cleared when the caller's `placeOrder` returns, + // which for a chase is before any replacement runs. + const marksSubscriptionCloid = this.#isSubscriptionFeeSource(); // The preamble is several round trips long. A disconnect during it has // already torn down everything this session would run on, so the chase @@ -6594,6 +6608,7 @@ export class HyperLiquidProvider implements PerpsProvider { size: formattedSize, reduceOnly: params.reduceOnly ?? false, builder, + marksSubscriptionCloid, exchangeClient: placingClient, }); break; @@ -6649,6 +6664,7 @@ export class HyperLiquidProvider implements PerpsProvider { intervalMs, lastSnapshotSizeRefreshAt: 0, builder, + marksSubscriptionCloid, deadline: params.chaseMaxDurationMs === undefined ? Number.POSITIVE_INFINITY @@ -6786,6 +6802,9 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.size - Formatted size. * @param params.reduceOnly - Whether the order may only reduce a position. * @param params.builder - Builder context captured when the session started. + * @param params.marksSubscriptionCloid - Whether to mark the cloid, captured + * when the session started. Passed explicitly rather than read live: a + * replacement runs long after the fee resolution behind it was cleared. * @param params.exchangeClient - Client to submit through. Passed in rather * than looked up here so a first placement can keep the instance it signed * with, which is the only one that can take the order back once `disconnect` @@ -6799,21 +6818,26 @@ export class HyperLiquidProvider implements PerpsProvider { size: string; reduceOnly: boolean; builder?: BuilderOrderContext; + marksSubscriptionCloid?: boolean; exchangeClient: ExchangeClient; }): Promise { const result = await params.exchangeClient.order({ - orders: this.#applySubscriptionCloid([ - { - a: params.assetId, - b: params.isBuy, - p: params.price, - s: params.size, - r: params.reduceOnly, - // Post-only: a chase adds liquidity at the touch. Crossing would end - // the chase on its first tick at a worse price than resting does. - t: { limit: { tif: 'Alo' as const } }, - }, - ]), + orders: this.#applySubscriptionCloid( + [ + { + a: params.assetId, + b: params.isBuy, + p: params.price, + s: params.size, + r: params.reduceOnly, + // Post-only: a chase adds liquidity at the touch. Crossing would + // end the chase on its first tick at a worse price than resting. + t: { limit: { tif: 'Alo' as const } }, + }, + ], + undefined, + params.marksSubscriptionCloid, + ), grouping: 'na', ...(params.builder && { builder: params.builder }), }); @@ -7196,6 +7220,7 @@ export class HyperLiquidProvider implements PerpsProvider { size: remaining, reduceOnly: session.reduceOnly, builder: session.builder, + marksSubscriptionCloid: session.marksSubscriptionCloid, // A running session is on a live provider, so the current client is // the right one; only the first placement has a teardown to survive. exchangeClient: this.#clientService.getExchangeClient(), @@ -8634,13 +8659,20 @@ export class HyperLiquidProvider implements PerpsProvider { * @param generatedCloids - Cloids this package generated for these orders and * may therefore re-stamp. Only the Scale ladder supplies any; every other path * either has no cloid or carries the caller's own. + * @param marksSubscriptionCloid - Overrides the live fee resolution for a + * chase replacement, which runs after that resolution has been cleared. * @returns The same payloads, with cloids marked when subscription won. */ #applySubscriptionCloid( orders: SDKOrderParams[], generatedCloids?: ReadonlySet, + marksSubscriptionCloid?: boolean, ): SDKOrderParams[] { - if (!this.#isSubscriptionFeeSource()) { + // A chase replacement decides from the session, because the live resolution + // was cleared when the caller's `placeOrder` returned. Everything else + // decides from the resolution in flight. + const marks = marksSubscriptionCloid ?? this.#isSubscriptionFeeSource(); + if (!marks) { // Any other source leaves the id exactly as the caller built it. return orders; } diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index f9f394b2f2f..0bebe4dcd12 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1183,21 +1183,22 @@ export class TradingService { * * @param options - The configuration options. * @param options.params - Which positions the batch will close. - * @param options.context - The service context, for the positions read. + * @param options.provider - The provider that will submit the batch, and so + * the only one whose positions it can close. * @returns The summed notional in USD, or undefined when it cannot be read. */ async #resolveBatchCloseNotionalUsd(options: { params: ClosePositionsParams; - context: ServiceContext; + provider: PerpsProvider; }): Promise { - const { params, context } = options; - - if (!context.getPositions) { - return undefined; - } + const { params, provider } = options; try { - const positions = await context.getPositions(); + // Read through the provider that will actually submit the batch. The + // context reader aggregates every provider's positions, and a batch close + // routes to one — summing the rest would inflate the notional and shrink + // the waiver for positions this call never touches. + const positions = await provider.getPositions(); // `closeAll`, or an omitted/empty symbol list, means every position. const selected = params.symbols && params.symbols.length > 0 @@ -2084,7 +2085,7 @@ export class TradingService { // The batch submits under one builder context, so its notional is the // sum of the positions it will close, not any single one of them. const feeResolution = await this.#calculateFeeDiscountWithMeasurement( - await this.#resolveBatchCloseNotionalUsd({ params, context }), + await this.#resolveBatchCloseNotionalUsd({ params, provider }), ); operationResult = await this.#withFeeDiscount({ diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 8a4fefbe18c..8a3d7abbbbe 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -55,9 +55,14 @@ export type PerpsSubscriptionWaiverRate = { * * An absent `remainingNotionalUsd` means the backend did not bound the * allowance, which stays a full waiver — the pre-existing behavior for an - * eligible gate that reports no cap. An absent or non-positive - * `orderNotionalUsd` means there is no notional to blend against (a pure rate - * preview), which also resolves to the full waiver rate. + * eligible gate that reports no cap. + * + * A *bounded* allowance with an absent or non-positive `orderNotionalUsd` does + * not apply at all. Quoting the full waiver there would charge nothing on an + * order whose size is unknown and silently over-consume the cap; withholding is + * the fail-closed direction, and the same one an exhausted or stale gate takes. + * A rate-only preview of a bounded allowance therefore quotes the next-lowest + * source rather than a waiver the order may not receive. * * Pure, so preview and submit consume exactly the same arithmetic and their * quoted and charged fees cannot drift. @@ -91,13 +96,16 @@ export function resolveSubscriptionWaiverRate(params: { return { applies: false, feeBips: maxFeeBips, kind: 'none' }; } - // No notional to blend against: a rate-only preview quotes the full waiver. + // A bounded allowance with no notional to measure it against cannot be + // honoured: granting the full waiver would charge nothing on an order of + // unknown size and over-consume the cap. Withholding is the fail-closed + // direction, and matches how an exhausted or stale gate already behaves. if ( orderNotionalUsd === undefined || !Number.isFinite(orderNotionalUsd) || orderNotionalUsd <= 0 ) { - return { applies: true, feeBips: 0, kind: 'full' }; + return { applies: false, feeBips: maxFeeBips, kind: 'none' }; } if (remaining >= orderNotionalUsd) { diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index fde108fa28e..34e018301c7 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1832,9 +1832,10 @@ describe('PerpsController', () => { // ADR 0064 replaced the dedicated builder with cloid marking, so this // stays a no-op rather than reaching the provider — even when the - // provider still exposes the old approval method. + // provider still exposes the old approval method. It resolves `true` + // because nothing needs approving; `false` would read as setup failure. await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( - false, + true, ); expect(approve).not.toHaveBeenCalled(); }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index e3d89fe6c82..061d02a8d4b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -8744,6 +8744,68 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(order).toHaveBeenCalledTimes(2); expect(order.mock.calls[1][0].builder.f).toBe(quotedFee); }); + + it('marks the replacement cloid after the subscription context is cleared', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + + useStrategyClients({ + exchange: { order }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + subscriptionWaiverKind: 'full', + }); + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + expect( + hasFeeReductionAppliedFlag(order.mock.calls[0][0].orders[0].c), + ).toBe(true); + + // TradingService clears the resolution as soon as placeOrder returns, + // long before the chase re-prices. The replacement still pays the + // discounted fee, so it must still carry the attribution. + provider.setUserFeeResolution(undefined); + await jest.advanceTimersByTimeAsync(1000); + + expect(order).toHaveBeenCalledTimes(2); + expect( + hasFeeReductionAppliedFlag(order.mock.calls[1][0].orders[0].c), + ).toBe(true); + }); }); describe('Chase re-prices only what is still resting', () => { diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 2ad892b5c6c..19ab08f39cf 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -335,7 +335,7 @@ describe('RewardsIntegrationService', () => { ); await service.refreshSubscriptionBenefits(); expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - expect(await service.resolveFee()).toMatchObject({ + expect(await service.resolveFee(1000)).toMatchObject({ feeBips: 0, discountBips: 10000, source: 'subscription', @@ -345,7 +345,7 @@ describe('RewardsIntegrationService', () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(null); - expect(await service.resolveFee()).toMatchObject({ + expect(await service.resolveFee(1000)).toMatchObject({ feeBips: 0, discountBips: 10000, source: 'subscription', @@ -399,7 +399,7 @@ describe('RewardsIntegrationService', () => { service = new RewardsIntegrationService(mockDeps, mockMessenger); await service.refreshSubscriptionBenefits(); - const resolution = await service.resolveFee(); + const resolution = await service.resolveFee(1000); expect(resolution.subscription).toStrictEqual( expect.objectContaining({ @@ -523,7 +523,7 @@ describe('RewardsIntegrationService', () => { mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(0); await service.refreshSubscriptionBenefits(); - expect(await service.resolveFee()).toMatchObject({ + expect(await service.resolveFee(1000)).toMatchObject({ source: 'subscription', feeBips: 0, }); @@ -698,7 +698,7 @@ describe('RewardsIntegrationService', () => { return 6500; }); - const resolution = await service.resolveFee(); + const resolution = await service.resolveFee(1000); expect(getPerpsBenefits).toHaveBeenCalled(); expect(resolution.subscription.eligible).toBe(true); @@ -713,7 +713,7 @@ describe('RewardsIntegrationService', () => { ).mockResolvedValue(6500); await service.refreshSubscriptionBenefits(); - expect(await service.calculateUserFeeDiscount()).toBe(10000); + expect(await service.calculateUserFeeDiscount(1000)).toBe(10000); }); it('waives the whole fee when the remaining allowance covers the order notional', async () => { @@ -878,7 +878,7 @@ describe('RewardsIntegrationService', () => { expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); }); - it('quotes the full waiver when no order notional is supplied', async () => { + it('withholds a bounded allowance when no order notional is supplied', async () => { wireSubscription( jest .fn() @@ -889,7 +889,30 @@ describe('RewardsIntegrationService', () => { ).mockResolvedValue(0); await service.refreshSubscriptionBenefits(); - // A rate-only preview has nothing to blend against. + // A rate-only preview has nothing to measure the cap against, so granting + // a full waiver would quote free trading and over-consume the allowance. + const resolution = await service.resolveFee(); + + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + // The gate still passed; only the rate was withheld. + expect(resolution.subscription.eligible).toBe(true); + }); + + it('still waives an unbounded allowance with no order notional', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue( + createBenefits({ remainingNotionalUsd: undefined }), + ), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + const resolution = await service.resolveFee(); expect(resolution.source).toBe('subscription'); diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index ee8be7593b9..aaafef3cbb4 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -112,15 +112,33 @@ describe('resolveSubscriptionWaiverRate', () => { }); it.each([undefined, 0, -100, Number.NaN])( - 'quotes the full waiver rate when the order notional is %p', + 'withholds a bounded allowance when the order notional is %p', (orderNotionalUsd) => { + // Granting the full waiver here would charge nothing on an order of + // unknown size and silently over-consume the cap, so the source drops out + // rather than failing open. expect( resolveSubscriptionWaiverRate({ status: createStatus({ remainingNotionalUsd: 250 }), maxFeeBips: MAX_FEE_BIPS, orderNotionalUsd, }), - ).toMatchObject({ applies: true, feeBips: 0, kind: 'full' }); + ).toStrictEqual({ applies: false, feeBips: MAX_FEE_BIPS, kind: 'none' }); + }, + ); + + it.each([undefined, 0])( + 'still waives an unbounded allowance when the order notional is %p', + (orderNotionalUsd) => { + // No reported cap means nothing to over-consume, so a rate-only preview + // keeps quoting the full waiver. + expect( + resolveSubscriptionWaiverRate({ + status: createStatus(), + maxFeeBips: MAX_FEE_BIPS, + orderNotionalUsd, + }), + ).toStrictEqual({ applies: true, feeBips: 0, kind: 'full' }); }, ); }); From 72bae577d281a4e74e7fc3902e531eb7546d80a5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 00:33:42 +0800 Subject: [PATCH 06/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects, three of them in fixes from earlier rounds. A rejected messenger benefits read could erase a valid cached snapshot. The catch claimed to fall back rather than erase, but with no injected subscription dependency it fell through to null, which the refresh then stored as a successful "no subscription" result — wiping a waiver the user still held. The rejection now propagates when nothing else can answer, so the outer handler keeps the previous snapshot. Closes priced against positions their write could not reach. The previous round routed batch-close pricing through provider.getPositions() on the assumption that it read only the submitting provider; in aggregated mode it spans every active provider while the write goes to the default one. A routed single close had the same shape through symbol-only matching, so with two providers listing one market it could price the wrong provider's position. AggregatedPerpsProvider now reports which provider a write reaches — protocolId names the aggregate and reads span providers, so nothing exposed this — and pricing filters on the providerId the aggregator already injects. Preview quoted an unfloored fractional fee while submit floored to the venue's tenths of a basis point, so a 6.667-bip blend was quoted at 6.667 and charged at 6.6. Both paths now share one quantization helper. The migration note promised that omitting the notional preserves a full waiver, which the previous round reversed for bounded allowances. Corrected, and the breaking note now also covers rewards repricing and the quantization change. Also corrects the evidence rather than the criterion: AC4 requires every winning placement to mark the cloid, and caller-supplied client order IDs and Scale rungs do not, so it is recorded as PARTIAL with the recipe-quality verdict moved to warn. The underlying attribution gap needs a product decision and stays open in the task report. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 6 +- .../src/providers/AggregatedPerpsProvider.ts | 15 +++++ .../src/providers/HyperLiquidProvider.ts | 11 +-- .../src/services/RewardsIntegrationService.ts | 25 +++++-- .../src/services/TradingService.ts | 67 +++++++++++++++++-- packages/perps-controller/src/types/index.ts | 12 ++++ .../src/utils/subscriptionFeeWaiver.ts | 40 +++++++++-- .../RewardsIntegrationService.test.ts | 34 ++++++++++ .../tests/src/services/TradingService.test.ts | 66 ++++++++++++++++++ .../src/utils/subscriptionFeeWaiver.test.ts | 26 +++++++ 10 files changed, 278 insertions(+), 24 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 7d23c44ad00..06bd6664f73 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `PerpsController.calculateFees` now quotes the subscription fee waiver as a blended rate derived from the order notional, so `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from previous releases when a subscription waiver applies. - - Pass the order notional (USD) as `FeeCalculationParams.amount` to receive the rate the order will actually be charged. Omitting it quotes the full-waiver rate, matching the previous behavior. + - Pass the order notional (USD) as `FeeCalculationParams.amount`. It is now required for quote/submit parity, and omitting it changes the quote rather than preserving the previous one: a waiver whose remaining allowance is bounded is withheld entirely from a quote with no notional, so the preview reports the next-lowest source while a submit that can derive a notional still applies the waiver. A waiver with no reported allowance bound is unaffected. + - Quoted rates are also repriced when rewards win, not only under a subscription waiver, and are quantized to the venue's tenths of a basis point so a quote equals the charged rate. - Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. - Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins and actually reduced the fee. Any other fee source leaves the client order ID untouched, as does a subscription waiver whose remaining allowance is too small to change the charged fee. - A client order ID supplied by the caller through `OrderParams.clientOrderId` is never rewritten, so such orders are submitted exactly as requested and are not attributed to the subscription program. Only client order IDs this package generates carry the marking, and that provenance is tracked explicitly rather than inferred from the ID's leading bytes — a caller-supplied ID beginning with a reserved marker is still preserved. @@ -38,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. - Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. - Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. +- Preserve a cached benefits snapshot when a registered `SubscriptionController:getPerpsBenefits` handler rejects and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. +- Price a close from the position the write can actually reach. A close read positions across every active provider and matched on symbol alone, so in aggregated mode a batch close summed positions it could not close, and a routed single close could price another provider's position for the same symbol. +- Quantize the previewed MetaMask builder fee to the venue's tenths of a basis point, matching what submit charges. A blended rate of 6.667 bips was previously quoted as 6.667 and charged as 6.6. - Hydrate subscription benefits for a client that registers `SubscriptionController:getPerpsBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. - Price a position close from the loaded position when the close parameters do not carry a notional. A full close commonly passes only a symbol, which previously resolved as an unbounded waiver rather than blending against the position's value; a partial close is now priced from the position's value per unit. - Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index e0d7bd9725a..d6f94f9b18e 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -213,6 +213,21 @@ export class AggregatedPerpsProvider implements PerpsProvider { return provider; } + /** + * Which provider a write with this route actually reaches. + * + * `protocolId` is `aggregated` and `getPositions` spans every active + * provider, so a caller that needs to reason about one write — pricing the + * positions a close can actually touch, for instance — cannot infer the route + * from either. This reports it explicitly. + * + * @param providerId - Explicit route, or undefined for the default. + * @returns The provider id the write will be submitted through. + */ + getWriteProviderId(providerId?: PerpsProviderType): PerpsProviderType { + return providerId ?? this.#defaultProvider; + } + /** * Get the explicit provider, or the default when no route was supplied. * diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index f6e0cf38218..2ce0036bd4f 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -238,7 +238,10 @@ import { queryStandaloneOpenOrders, } from '../utils/standaloneInfoClient.js'; import { parseBoundedNonNegativeDecimal } from '../utils/stringParseUtils.js'; -import { markSubscriptionCloid } from '../utils/subscriptionFeeWaiver.js'; +import { + markSubscriptionCloid, + quantizeBuilderFeeTenthsBps, +} from '../utils/subscriptionFeeWaiver.js'; // getStreamManagerInstance removed: use this.#deps.streamManager instead const HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE = { @@ -8591,10 +8594,8 @@ export class HyperLiquidProvider implements PerpsProvider { if (this.#userFeeDiscountBips === undefined) { return BUILDER_FEE_CONFIG.MaxFeeTenthsBps; } - return Math.floor( - BUILDER_FEE_CONFIG.MaxFeeTenthsBps * - (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), - ); + // Shared with the preview so a quoted rate is the rate the venue charges. + return quantizeBuilderFeeTenthsBps(this.#userFeeDiscountBips); } /** diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 3484d93a441..9d315e90511 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -490,18 +490,31 @@ export class RewardsIntegrationService { // Unregistered action or a throwing handler: fall through to the DI source. } + const fallback = this.#deps.subscription; + if (pending) { try { return await pending; - } catch { - // A registered handler that rejects still falls back rather than - // erasing the cached snapshot. + } catch (error) { + if (!fallback) { + // Nothing else can answer, so this rejection is the whole result. + // Returning `null` here would be stored as a successful "no + // subscription" snapshot and silently erase a valid cached one; let + // it reach the refresh handler, which keeps the previous snapshot. + throw error; + } + // A registered handler that rejects still falls back to the injected + // source rather than erasing the cached snapshot. } } - // No handler answered. Fall back to the injected source when one exists; - // otherwise there is genuinely nothing to report. - return (await this.#deps.subscription?.getPerpsBenefits()) ?? null; + if (!fallback) { + // No handler answered and no injected source: genuinely nothing to + // report, which is a real `null` rather than a swallowed failure. + return null; + } + + return await fallback.getPerpsBenefits(); } /** diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 0bebe4dcd12..b0a3601de9b 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -15,6 +15,7 @@ import { } from '../types/index.js'; import type { PerpsProvider, + PerpsProviderType, OrderParams, OrderResult, EditOrderParams, @@ -776,20 +777,31 @@ export class TradingService { * @param options - The configuration options. * @param options.symbol - The trading pair symbol. * @param options.context - The service context for dependencies. + * @param options.provider - The provider the write will be submitted through, + * used to narrow the read to positions that route can reach. Omit for a + * read-only lookup that does not precede a write. + * @param options.providerId - Explicit route, when the caller supplied one. * @returns The result of the operation. */ async #loadPositionData(options: { symbol: string; context: ServiceContext; + provider?: PerpsProvider; + providerId?: PerpsProviderType; }): Promise { - const { symbol, context } = options; + const { symbol, context, provider, providerId } = options; const positionLoadStart = this.#deps.performance.now(); try { const positions = context.getPositions ? await context.getPositions() : []; - const position = positions.find((pos) => pos.symbol === symbol); + // Matching on symbol alone can pick another provider's position when two + // providers list the same market, so a routed write narrows first. + const candidates = provider + ? this.#positionsForWriteRoute({ positions, provider, providerId }) + : positions; + const position = candidates.find((pos) => pos.symbol === symbol); this.#deps.tracer.setMeasurement( PerpsMeasurementName.PerpsGetPositionsOperation, @@ -1194,11 +1206,15 @@ export class TradingService { const { params, provider } = options; try { - // Read through the provider that will actually submit the batch. The - // context reader aggregates every provider's positions, and a batch close - // routes to one — summing the rest would inflate the notional and shrink - // the waiver for positions this call never touches. - const positions = await provider.getPositions(); + // Read through the provider that will actually submit the batch, then + // keep only what that route can close: an aggregating provider's + // `getPositions` still spans every active provider, so summing the rest + // would inflate the notional and shrink the waiver for positions this + // call never touches. + const positions = this.#positionsForWriteRoute({ + positions: await provider.getPositions(), + provider, + }); // `closeAll`, or an omitted/empty symbol list, means every position. const selected = params.symbols && params.symbols.length > 0 @@ -1230,6 +1246,41 @@ export class TradingService { } /** + * Keep only the positions a write through this route can actually touch. + * + * An aggregating provider reads positions from every active provider while a + * write goes to one, so pricing a close against the unfiltered list can bill + * against positions the call cannot close — and, when two providers list the + * same symbol, can price one provider's position for a write submitted to + * another. A provider that does not aggregate reports no write route and its + * positions are all its own. + * + * @param options - The configuration options. + * @param options.positions - Positions as read, possibly across providers. + * @param options.provider - The provider the write will be submitted through. + * @param options.providerId - Explicit route, when the caller supplied one. + * @returns The positions that route can reach. + */ + #positionsForWriteRoute(options: { + positions: Position[]; + provider: PerpsProvider; + providerId?: PerpsProviderType; + }): Position[] { + const { positions, provider, providerId } = options; + const route = provider.getWriteProviderId?.(providerId); + if (route === undefined) { + return positions; + } + // A position carries its provider only when an aggregator injected one; + // without that attribution there is nothing to filter on. + return positions.filter( + (position) => + position.providerId === undefined || position.providerId === route, + ); + } + + /** + * The position's USD value per unit of size. /** * The position's USD value per unit of size. * * Used to price a partial close, which names a size but usually no price. @@ -1902,6 +1953,8 @@ export class TradingService { position = await this.#loadPositionData({ symbol: params.symbol, context, + provider, + providerId: params.providerId, }); // Emit submitted event before the provider round-trip diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 281f1bc7f67..d64b0ef4412 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2095,6 +2095,18 @@ export type PerpsProvider = { getBlockExplorerUrl(address?: string): string; // Fee discount context (optional - for MetaMask reward discounts) + /** + * Which provider a write with this route actually reaches. + * + * Implemented only by an aggregating provider, whose `protocolId` names the + * aggregate rather than the route and whose reads span every active provider. + * A single provider needs no answer: every write reaches itself. + * + * @param providerId - Explicit route, or undefined for the default. + * @returns The provider id the write will be submitted through. + */ + getWriteProviderId?(providerId?: PerpsProviderType): PerpsProviderType; + setUserFeeDiscount?(discountBips: number | undefined): void; // Full fee resolution context, including attribution source. setUserFeeResolution?(resolution: PerpsFeeResolution | undefined): void; diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 8a3d7abbbbe..77e35e20887 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -125,6 +125,14 @@ export function resolveSubscriptionWaiverRate(params: { }; } +/** + * Tenths of a basis point in one unit rate. + * + * `MaxFeeTenthsBps` is `MaxFeeDecimal * 100000`, so dividing a tenths-of-a-bip + * figure by this returns the same decimal rate the fee quote reports. + */ +const BUILDER_FEE_TENTHS_BPS_PER_UNIT = 100_000; + /** Hex index of the flag byte inside a cloid string (after `0x` + 4 bytes). */ const FLAG_BYTE_START = 2 + SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength; @@ -173,9 +181,8 @@ export function readSubscriptionCloidFlags( * decoder that needs Scale attribution has to correlate on something other than * the cloid, or wait for a ladder layout that carries a version. * - * Note also that {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId} is still a - * placeholder pending the cloid registry value, so a decoder built on this must - * be re-pointed when the real id lands. + * The marker it checks is {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId}, the + * registered perps-subscription program id. * * @param clientOrderId - A venue client order ID, or nothing. * @returns True when the `fee_reduction_applied` flag is set behind the @@ -318,6 +325,27 @@ export function markSubscriptionCloid(params: { } /** + * The MetaMask builder fee a discount actually buys, in tenths of a basis point. + * + * HyperLiquid's builder fee is an integer number of tenths of a basis point, so + * the venue floors whatever fraction a discount implies. Both preview and submit + * resolve the charged fee through here: quoting the unfloored fraction would + * promise a rate the venue cannot charge — a 6.667-bip blend is submitted as + * 6.6 — and the difference, though always in the user's favour, is a + * quote-versus-charge mismatch of exactly the kind this change set out to close. + * + * @param discountBips - Discount off the default builder fee, in basis points. + * @returns The charged builder fee in tenths of a basis point. + */ +export function quantizeBuilderFeeTenthsBps(discountBips: number): number { + return Math.floor( + BUILDER_FEE_CONFIG.MaxFeeTenthsBps * + (1 - discountBips / BASIS_POINTS_DIVISOR), + ); +} + +/** + * Re-price a fee quote from the unified fee resolution./** * Re-price a fee quote from the unified fee resolution. * * The provider quotes the MetaMask component from whatever discount the last @@ -350,9 +378,11 @@ export function applyFeeResolution(params: { return fees; } - const baseMetamaskFeeRate = BUILDER_FEE_CONFIG.MaxFeeDecimal; + // Quantized exactly as the venue will charge it, so the quote matches the + // fill rather than the unfloored fraction the discount implies. const metamaskFeeRate = - baseMetamaskFeeRate * (1 - resolution.discountBips / BASIS_POINTS_DIVISOR); + quantizeBuilderFeeTenthsBps(resolution.discountBips) / + BUILDER_FEE_TENTHS_BPS_PER_UNIT; const parsedAmount = amount === undefined ? undefined : Number.parseFloat(amount); const notional = diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 19ab08f39cf..b1c0b380f4e 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1014,6 +1014,40 @@ describe('RewardsIntegrationService', () => { expect(resolution.subscriptionWaiverKind).toBe('partial'); }); + it('keeps a cached snapshot when a messenger-only benefits read rejects', async () => { + // With no DI fallback the rejection is the whole result. Swallowing it + // would store `null` as a successful "no subscription" snapshot and erase + // a waiver the user is still entitled to. + const messengerBenefits = jest + .fn() + .mockResolvedValueOnce(createBenefits({ remainingNotionalUsd: 250 })); + setupMessengerDefaults({ + 'SubscriptionController:getPerpsBenefits': messengerBenefits, + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + expect(mockDeps.subscription).toBeUndefined(); + + await service.refreshSubscriptionBenefits(); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + + // The next read fails outright. + messengerBenefits.mockRejectedValue(new Error('benefits endpoint down')); + jest.setSystemTime(NOW + FRESH_MS + 1); + await expect( + service.refreshSubscriptionBenefits(), + ).resolves.toBeUndefined(); + + // The previous snapshot survives rather than being replaced by null. + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 250, + }); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + it('still reports no source when neither wiring is present', async () => { setupMessengerDefaults(); ( diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index a3c2ae5d71c..3e08a338225 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1811,6 +1811,31 @@ describe('TradingService', () => { ); }); + it('prices a routed close from the routed provider position', async () => { + // Two providers list BTC. Matching on symbol alone would price the close + // from whichever appears first, which can be the provider the write does + // not reach. + mockGetPositions.mockResolvedValue([ + { ...mockPosition, positionValue: '99000', providerId: 'lighter' }, + { ...mockPosition, positionValue: '25000', providerId: 'hyperliquid' }, + ]); + mockProvider.getWriteProviderId = jest.fn( + (providerId?: string) => providerId ?? 'hyperliquid', + ) as never; + mockProvider.closePosition.mockResolvedValue({ success: true }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC', providerId: 'hyperliquid' as never }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 25000, + ); + }); + it('prefers an explicit close USD amount over the position value', async () => { mockGetPositions.mockResolvedValue([mockPosition]); mockProvider.closePosition.mockResolvedValue({ success: true }); @@ -2046,6 +2071,47 @@ describe('TradingService', () => { }); describe('closePositions', () => { + it('prices a batch close only from positions the route can close', async () => { + // An aggregating provider reads every active provider's positions while + // the batch submits through one. Summing the rest inflates the notional + // and shrinks the waiver for positions this call never touches. + const batchProvider = { + ...mockProvider, + getWriteProviderId: jest.fn(() => 'hyperliquid'), + getPositions: jest.fn().mockResolvedValue([ + { + symbol: 'BTC', + size: '0.5', + positionValue: '25000', + providerId: 'hyperliquid', + }, + { + symbol: 'ETH', + size: '2', + positionValue: '99000', + providerId: 'lighter', + }, + ]), + closePositions: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }), + } as unknown as jest.Mocked; + + await tradingService.closePositions({ + provider: batchProvider, + params: { closeAll: true }, + context: mockContext, + }); + + // 25000 only — the lighter position is not reachable by this write. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 25000, + ); + }); + const mockPositions: Position[] = [ { symbol: 'BTC', diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index aaafef3cbb4..a195a8e59dd 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -9,6 +9,7 @@ import { hasFeeReductionAppliedFlag, isSubscriptionProgramCloid, markSubscriptionCloid, + quantizeBuilderFeeTenthsBps, readSubscriptionCloidFlags, resolveSubscriptionWaiverRate, } from '../../../src/utils/subscriptionFeeWaiver.js'; @@ -403,6 +404,31 @@ describe('applyFeeResolution', () => { expect(priced.metamaskFeeAmount).toBe(0); }); + it('quotes the venue-quantized rate the submit path charges', () => { + // A 3333-bip discount off a 10-bip max implies 6.667 bips, but the venue + // charges integer tenths of a basis point and floors to 6.6. Quoting the + // unfloored fraction would reintroduce a quote-versus-charge gap. + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 6.667, + discountBips: 3333, + source: 'subscription', + subscription: createStatus({ remainingNotionalUsd: 333 }), + subscriptionWaiverKind: 'partial', + }, + amount: '1000', + }); + + const chargedTenthsBps = quantizeBuilderFeeTenthsBps(3333); + expect(chargedTenthsBps).toBe(66); + expect((priced.metamaskFeeRate ?? 0) * 10000).toBeCloseTo(6.6, 10); + expect((priced.metamaskFeeRate ?? 0) * 10000).toBeCloseTo( + chargedTenthsBps / 10, + 10, + ); + }); + it('leaves the quote untouched when no source resolved', () => { expect( applyFeeResolution({ From da707d193812bf2783d1b771cffe0f65696cb197 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 08:34:13 +0800 Subject: [PATCH 07/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the real SubscriptionController instead of an invented one. This branch declared SubscriptionController:getPerpsBenefits and SubscriptionController:registerAddress as structural types, on the stated premise that SubscriptionController does not live in this monorepo. It does: packages/subscription-controller exposes SubscriptionController:getBenefits, and neither of the two names this branch used exists anywhere. The real contract also differs — allowances arrive in micro-USD, eligibility is a response flag rather than a status string — so the shape being decoded was wrong too. perps-controller now depends on @metamask/subscription-controller, imports its action type rather than restating it, and converts micro-USD to USD once at the boundary so the gate and the blended-rate formula keep working in whole USD. The registerAddress action is removed; address registration runs through an optional hook on the injected dependency until a real action exists, rather than calling a name nothing answers. Four smaller defects: a fee preview returned the provider's own rate when the default source won, which reflects whatever discount the last submit pushed into it and could leak a concurrent order's discount into an unrelated quote; a take-profit/stop-loss update with neither a position snapshot nor tracking data resolved no notional and, since bounded waivers now fail closed, silently lost the waiver; a synchronous throw from a registered benefits handler was indistinguishable from an unregistered action and fell through to null, erasing a cached snapshot; and an account switch cleared registration without registering the new address, so an order submitted before the next preview went unattributed. Also corrects public type documentation that claimed quoted rates are not adjusted from the subscription waiver, and a coverage document that recorded AC4 as PARTIAL and then counted it as proven. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 12 +- packages/perps-controller/package.json | 1 + .../perps-controller/src/PerpsController.ts | 15 ++ packages/perps-controller/src/index.ts | 12 -- .../src/services/RewardsIntegrationService.ts | 112 ++++++++---- .../src/services/TradingService.ts | 23 ++- packages/perps-controller/src/types/index.ts | 19 +- .../perps-controller/src/types/messenger.ts | 39 +--- .../src/utils/subscriptionFeeWaiver.ts | 11 +- .../src/PerpsController.operations.test.ts | 10 +- .../RewardsIntegrationService.test.ts | 171 +++++++++++------- .../src/utils/subscriptionFeeWaiver.test.ts | 33 ++-- packages/perps-controller/tsconfig.build.json | 3 + packages/perps-controller/tsconfig.json | 3 + yarn.lock | 1 + 15 files changed, 294 insertions(+), 171 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 06bd6664f73..6ea0444bb54 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. -- Add `SubscriptionController:getPerpsBenefits` and `SubscriptionController:registerAddress` to `PerpsControllerAllowedActions`, so benefits hydration and trading-address registration can run over the messenger. Clients that do not register these actions keep using the injected `subscription` dependency. +- Add `SubscriptionController:getBenefits` to `PerpsControllerAllowedActions`, so benefits hydration runs over the action `@metamask/subscription-controller` already exposes. Its micro-USD allowances are converted to USD at the boundary. Clients that do not register the action keep using the injected `subscription` dependency. - Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. - Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID, and add an exact `./utils` subpath export so the barrel is importable as `@metamask/perps-controller/utils`. -- Export `SubscriptionControllerGetPerpsBenefitsAction` and `SubscriptionControllerRegisterAddressAction` from the package index, so a client registering these handlers does not have to restate their shapes. +- Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. `SubscriptionController` exposes no address-registration action, so this runs through the injected dependency until one exists. ### Changed @@ -39,13 +39,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. - Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. - Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. -- Preserve a cached benefits snapshot when a registered `SubscriptionController:getPerpsBenefits` handler rejects and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. +- Preserve a cached benefits snapshot when a registered `SubscriptionController:getBenefits` handler rejects or throws synchronously and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. +- Reprice a fee preview to the undiscounted builder fee when the default source wins, rather than returning the provider's own rate. The provider's rate reflects the discount the last submit pushed into it, so a concurrent order could leak its discount into an unrelated quote. +- Price a take-profit/stop-loss update from the position read back through the routed provider when the caller supplies neither a position snapshot nor tracking data. Both are optional, and a bounded waiver is withheld without a notional, so a valid update silently lost the waiver. - Price a close from the position the write can actually reach. A close read positions across every active provider and matched on symbol alone, so in aggregated mode a batch close summed positions it could not close, and a routed single close could price another provider's position for the same symbol. - Quantize the previewed MetaMask builder fee to the venue's tenths of a basis point, matching what submit charges. A blended rate of 6.667 bips was previously quoted as 6.667 and charged as 6.6. -- Hydrate subscription benefits for a client that registers `SubscriptionController:getPerpsBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. +- Hydrate subscription benefits for a client that registers `SubscriptionController:getBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. - Price a position close from the loaded position when the close parameters do not carry a notional. A full close commonly passes only a symbol, which previously resolved as an unbounded waiver rather than blending against the position's value; a partial close is now priced from the position's value per unit. - Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. -- Attempt `SubscriptionController:registerAddress` whether or not the optional `subscription` dependency is injected. Registration was previously gated on that dependency, so a client that wired the messenger actions instead of the dependency silently registered nothing. A registration that no handler answers is no longer recorded as sent, so a `SubscriptionController` registered after the first fee preview still receives the address. +- Register the newly selected trading address immediately on an account switch. Clearing the session's registrations alone only re-registered on the next fee preview, so an order submitted straight after a switch went unattributed. - Normalize Lighter order timestamps from seconds to milliseconds for client date displays. ([#10187](https://github.com/MetaMask/core/pull/10187)) - Accept omitted Lighter fill PnL only when the account's validated pre-trade position is zero; retain strict PnL validation for existing positions and malformed supplied values. ([#10187](https://github.com/MetaMask/core/pull/10187)) diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 672e21277c1..d922870dc57 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -91,6 +91,7 @@ "@metamask/network-controller": "^37.0.0", "@metamask/profile-sync-controller": "^32.1.1", "@metamask/remote-feature-flag-controller": "^7.0.0", + "@metamask/subscription-controller": "^9.0.1", "@metamask/transaction-controller": "^70.1.0", "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 3f8eb9c8041..02912be90d6 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1283,6 +1283,21 @@ export class PerpsController extends BaseController< // carry this. const forgetRegisteredTradingAddresses = (): void => { this.#rewardsIntegrationService.resetRegisteredTradingAddresses(); + // Clearing alone only guarantees the *next preview* re-registers. An + // order submitted straight after a switch, with no preview in between, + // would otherwise be attributed to nothing, so the new address announces + // itself here. Fire-and-forget: attribution plumbing must not block or + // fail an account switch. + const switchedAccount = getSelectedEvmAccountFromMessenger( + this.messenger, + ); + if (switchedAccount) { + this.#rewardsIntegrationService + .registerTradingAddress(switchedAccount.address) + .catch(() => { + /* never blocks an account switch */ + }); + } }; this.messenger.subscribe( 'AccountsController:selectedAccountChange', diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 7d9707ff77e..e97b7f34822 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -57,18 +57,6 @@ export type { ProPositionsSortDirection, ProPositionsSortField, } from './PerpsController.js'; -// The SubscriptionController action contracts ADR 0064 introduces. A client -// registering these handlers needs their shapes, and no package defines them -// yet — `SubscriptionController` does not live in this monorepo — so they are -// exported here rather than duplicated downstream. -// -// The `PerpsControllerAllowedActions`/`AllowedEvents` unions are deliberately -// not exported: the controller guidelines forbid exporting external-dependency -// unions from a package index, and lint enforces it. -export type { - SubscriptionControllerGetPerpsBenefitsAction, - SubscriptionControllerRegisterAddressAction, -} from './types/messenger.js'; export type { PerpsControllerApproveSubscriptionBuilderFeeAction, PerpsControllerCalculateFeesAction, diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 9d315e90511..363d64e7627 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -1,3 +1,5 @@ +import type { SubscriptionBenefitsResponse } from '@metamask/subscription-controller'; + import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, @@ -27,6 +29,9 @@ import { resolveSubscriptionWaiverRate } from '../utils/subscriptionFeeWaiver.js const DEFAULT_FEE_BIPS = BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR; +/** `SubscriptionController` reports allowances in micro-USD. */ +const MICRO_USD_PER_USD = 1_000_000; + /** * Cached subscription benefits plus the time they were read. */ @@ -98,7 +103,7 @@ export class RewardsIntegrationService { readonly #registeredTradingAddresses = new Set(); /** - * Whether `SubscriptionController:getPerpsBenefits` has ever answered on this + * Whether `SubscriptionController:getBenefits` has ever answered on this * messenger. Action registration can be delegated, in which case it does not * appear in `getRegisteredActionTypes`, so an answered call is the only * reliable proof that the messenger route is available. @@ -277,7 +282,7 @@ export class RewardsIntegrationService { /** * Whether this client has any way to read subscription benefits. * - * Either wiring counts: a registered `SubscriptionController:getPerpsBenefits` + * Either wiring counts: a registered `SubscriptionController:getBenefits` * action (the ADR 0064 target) or the legacy injected `subscription` callback. * Requiring the injected one would make the messenger path unreachable on * exactly the configuration it was added for, so the messenger is probed by @@ -298,7 +303,7 @@ export class RewardsIntegrationService { // regardless, and an answer sets `#messengerBenefitsAnswered` above. return this.#messenger .getRegisteredActionTypes() - .includes('SubscriptionController:getPerpsBenefits'); + .includes('SubscriptionController:getBenefits'); } catch { return false; } @@ -477,17 +482,23 @@ export class RewardsIntegrationService { // Called without awaiting so the fallback stays synchronous when no // handler is registered: the DI read must start in the same tick, or a // caller that inspects the in-flight state sees an idle service. - const result = this.#messenger.call( - 'SubscriptionController:getPerpsBenefits', - ); - // `null` is a real answer ("no subscription"); `undefined` means nothing - // handled the action, which is the fallback case rather than an answer. + const result = this.#messenger.call('SubscriptionController:getBenefits'); + // `undefined` means nothing handled the action, which is the fallback + // case rather than an answer. if (result !== undefined) { this.#messengerBenefitsAnswered = true; - pending = Promise.resolve(result); + pending = Promise.resolve(result).then(adaptSubscriptionBenefits); } - } catch { - // Unregistered action or a throwing handler: fall through to the DI source. + } catch (error) { + // A handler that has answered before exists, so a synchronous throw is a + // real failure rather than an unregistered action. Treating it as the + // latter would fall through to `null` and erase a valid cached snapshot, + // exactly as an asynchronous rejection would. + if (this.#messengerBenefitsAnswered && !this.#deps.subscription) { + throw error; + } + // Otherwise: unregistered action, or a throw with a DI source to fall + // back to. } const fallback = this.#deps.subscription; @@ -526,17 +537,20 @@ export class RewardsIntegrationService { * never throws: it is observability plumbing, and a failure here must not * block a fee preview. * - * Deliberately not gated on the injected `subscription` dependency: the - * messenger action exists precisely so a client can ship - * `SubscriptionController` *instead of* the DI callback, and requiring both - * would make registration unreachable on exactly that configuration. A client - * that registers neither lands in the catch below, which is already the - * unregistered-action path. + * `SubscriptionController` exposes no address-registration action today, so + * this runs entirely through the injected `subscription` dependency when a + * client supplies one. Wiring it to a messenger action is left until that + * action exists rather than calling a name nothing answers. * * @param address - The EVM trading address to register. * @returns A promise that resolves once the attempt settles. */ async registerTradingAddress(address: string): Promise { + const source = this.#deps.subscription; + if (!source?.registerTradingAddress) { + return; + } + try { const networkState = this.#messenger.call('NetworkController:getState'); const chainId = this.#getChainIdForNetwork( @@ -563,24 +577,7 @@ export class RewardsIntegrationService { return; } - const pending = this.#messenger.call( - 'SubscriptionController:registerAddress', - caipAccountId, - ); - - // An unregistered action can answer `undefined` rather than throwing. - // Treat that as "nothing handled it" and leave the dedupe cache alone, so - // a SubscriptionController registered later still receives the address - // instead of being skipped as already-registered. - if (pending === undefined) { - this.#deps.debugLogger.log( - 'RewardsIntegrationService: Trading address registration skipped', - { address, reason: 'no-handler' }, - ); - return; - } - - await pending; + await source.registerTradingAddress(caipAccountId); this.#registeredTradingAddresses.add(caipAccountId); this.#deps.debugLogger.log( @@ -588,8 +585,8 @@ export class RewardsIntegrationService { { caipAccountId }, ); } catch (error) { - // An unregistered action, an offline client, or a backend refusal all - // land here. None of them is a reason to fail a fee preview. + // An offline client or a backend refusal both land here. Neither is a + // reason to fail a fee preview. this.#deps.debugLogger.log( 'RewardsIntegrationService: Trading address registration skipped', { @@ -725,6 +722,47 @@ export class RewardsIntegrationService { } /** + * Convert `SubscriptionController`'s benefits response into the shape the + * waiver gate reads. + * + * The controller reports allowances in **micro-USD** (`remainingMicroUsd`) and + * carries no `status`/`entitled` fields: eligibility is the response's own + * `eligible` flag, and the perps product block holds the cap. Converting once, + * here at the boundary, keeps the gate and the blended-rate formula working in + * whole USD. + * + * A `remainingMicroUsd` of `null` means the backend reported no bound, which + * stays an unbounded allowance rather than a spent one. + * + * @param response - The controller's benefits response. + * @returns The internal benefits shape, or null when nothing is entitled. + */ +function adaptSubscriptionBenefits( + response: SubscriptionBenefitsResponse | null | undefined, +): PerpsSubscriptionBenefits | null { + if (!response) { + return null; + } + + const perps = response.products?.perps; + + return { + status: response.eligible ? 'active' : 'inactive', + perpsFeeWaiver: { + entitled: response.eligible && Boolean(perps), + usage: perps?.exhausted ? 'exhausted' : 'available', + exhausted: perps?.exhausted, + remainingNotionalUsd: + perps?.remainingMicroUsd === null || + perps?.remainingMicroUsd === undefined + ? undefined + : perps.remainingMicroUsd / MICRO_USD_PER_USD, + }, + }; +} + +/** + * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot./** * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot. * * The gate is `status=active` AND `perpsFeeWaiver` entitled AND diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index b0a3601de9b..90455b378ab 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -2349,15 +2349,28 @@ export class TradingService { // Get fee discount from rewards. A TP/SL update carries no notional of // its own, so it is priced from the position the triggers protect: the - // caller's position snapshot when there is one, else the size and entry - // price the tracking data carries. - const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + // caller's snapshot or tracking data when supplied, and otherwise the + // position read back through the routed provider. Both caller fields are + // optional, and a bounded waiver is withheld without a notional, so + // relying on them alone silently drops the waiver on a valid update. + const tpslNotionalUsd = this.#resolveOrderNotionalUsd({ usdAmount: params.position?.positionValue, size: params.trackingData?.positionSize?.toString(), currentPrice: params.trackingData?.entryPrice, - }), - ); + }) ?? + this.#resolveOrderNotionalUsd({ + usdAmount: ( + await this.#loadPositionData({ + symbol: params.symbol, + context, + provider, + providerId: params.providerId, + }) + )?.positionValue, + }); + const feeResolution = + await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd); // Execute with fee discount management result = await this.#withFeeDiscount({ diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index d64b0ef4412..7b123da6d16 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1698,8 +1698,13 @@ export type FeeCalculationResult = { /** * Read-only subscription fee-waiver preview, sourced from the same cached * benefits snapshot the fee resolver uses. Present only when the controller - * has a subscription source wired; the quoted rates above are not adjusted - * from it, so surfacing this never mutates the cap or the cache. + * has a subscription source wired. + * + * Surfacing this never mutates the cap or the cache. The rates above *are* + * adjusted from the unified fee resolution — including this waiver when it + * wins — so they reflect what the order will be charged rather than the + * undiscounted builder fee. Pass `FeeCalculationParams.amount` to get the + * rate an order of that size actually pays. */ subscription?: PerpsSubscriptionFeeWaiverStatus; }; @@ -2705,6 +2710,16 @@ export type PerpsPlatformDependencies = { * snapshot and never grants the waiver from a failed read. */ getPerpsBenefits(): Promise; + + /** + * Register the current HyperLiquid trading address (CAIP-10) against the + * subscription profile, so a later fill can be attributed to it. + * + * Optional: `SubscriptionController` exposes no address-registration action + * yet, so a client that cannot perform this simply omits it and the + * controller skips registration. + */ + registerTradingAddress?(caipAccountId: string): Promise; }; }; diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index 584f88ef0f7..c6430bfd023 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -27,43 +27,20 @@ import type { RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerStateChangeEvent, } from '@metamask/remote-feature-flag-controller'; +import type { SubscriptionControllerGetBenefitsAction } from '@metamask/subscription-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; -import type { PerpsSubscriptionBenefits } from './index.js'; - -/** - * Read the current profile's subscription benefits. - * - * ADR 0064 moves benefits hydration from the plain DI callback onto the - * messenger. The action is declared structurally rather than imported, because - * `SubscriptionController` does not live in this monorepo yet; a client that - * ships it registers an action with this exact name and signature, and a client - * that does not simply never registers it — {@link RewardsIntegrationService} - * falls back to the injected `subscription` dependency in that case. - */ -export type SubscriptionControllerGetPerpsBenefitsAction = { - type: 'SubscriptionController:getPerpsBenefits'; - handler: () => Promise; -}; - -/** - * Register a trading address against the current subscription profile. - * - * ADR 0064 requires the HyperLiquid trading address to be registered through - * `AddressIndex` at preview time, so a fill decoded off the HL fan-out can be - * attributed back to a profile. The address is CAIP-10. - */ -export type SubscriptionControllerRegisterAddressAction = { - type: 'SubscriptionController:registerAddress'; - handler: (caipAccountId: `${string}:${string}:${string}`) => Promise; -}; - /** * Actions from other controllers that PerpsController is allowed to call. + * + * `SubscriptionController:getBenefits` is the real action this monorepo's + * `SubscriptionController` already exposes, imported rather than restated so + * its signature cannot drift from the controller that serves it. A client that + * does not register it keeps the injected `subscription` dependency, which + * {@link RewardsIntegrationService} falls back to. */ export type PerpsControllerAllowedActions = - | SubscriptionControllerGetPerpsBenefitsAction - | SubscriptionControllerRegisterAddressAction + | SubscriptionControllerGetBenefitsAction | GeolocationControllerGetGeolocationAction | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 77e35e20887..2b557762b0e 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -371,18 +371,23 @@ export function applyFeeResolution(params: { const { fees, resolution, amount } = params; if ( - resolution?.discountBips === undefined || + resolution === undefined || fees.metamaskFeeRate === undefined || fees.metamaskFeeRate === 0 ) { return fees; } + // An unresolved discount means the `default` source won, which is a real + // answer of "no reduction" — not "leave the provider's number alone". The + // provider's rate reflects whatever discount the last submit pushed into it, + // so a concurrent order could otherwise leak its discount into this quote. + const discountBips = resolution.discountBips ?? 0; + // Quantized exactly as the venue will charge it, so the quote matches the // fill rather than the unfloored fraction the discount implies. const metamaskFeeRate = - quantizeBuilderFeeTenthsBps(resolution.discountBips) / - BUILDER_FEE_TENTHS_BPS_PER_UNIT; + quantizeBuilderFeeTenthsBps(discountBips) / BUILDER_FEE_TENTHS_BPS_PER_UNIT; const parsedAmount = amount === undefined ? undefined : Number.parseFloat(amount); const notional = diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 34e018301c7..a0a94b30ce4 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1797,6 +1797,9 @@ describe('PerpsController', () => { 'resetRegisteredTradingAddresses', ) .mockImplementation(() => undefined); + const register = jest + .spyOn(RewardsIntegrationService.prototype, 'registerTradingAddress') + .mockResolvedValue(undefined); // A controller built with a messenger this test holds, so the // lifetime subscription registered in the constructor is observable. @@ -1817,11 +1820,14 @@ describe('PerpsController', () => { accountHandlers.forEach((handler) => handler()); - // The session's registrations are dropped, so the next preview announces - // the new address instead of assuming the previous one still stands. + // The session's registrations are dropped, and the new address announces + // itself immediately — an order submitted straight after a switch, with + // no preview in between, would otherwise go unattributed. expect(reset).toHaveBeenCalled(); + expect(register).toHaveBeenCalled(); reset.mockRestore(); + register.mockRestore(); }); it('no longer approves a dedicated subscription builder', async () => { diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index b1c0b380f4e..5edcfad567c 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -273,6 +273,46 @@ describe('RewardsIntegrationService', () => { ...overrides, }) as never; + /** + * Build a `SubscriptionController:getBenefits` response. + * + * This is the controller's real contract: allowances in micro-USD, and + * eligibility on the response rather than a `status` string. + * + * @param perpsOverrides - Fields to override on the perps product block. + * @param overrides - Fields to override on the response itself. + * @returns A benefits response. + */ + const createBenefitsResponse = ( + perpsOverrides: Record = {}, + overrides: Record = {}, + ) => + ({ + eligible: true, + billingPeriodId: 'bp-1', + products: { + swaps: { + feeBips: null, + remainingMicroUsd: null, + exhausted: false, + }, + perps: { + builderFeeBips: null, + builderCode: null, + // 5000 USD, reported in micro-USD as the controller does. + remainingMicroUsd: 5_000_000_000, + exhausted: false, + ...perpsOverrides, + }, + predict: { + builderCode: null, + remainingTxCount: null, + exhausted: false, + }, + }, + ...overrides, + }) as never; + /** * Wire a subscription benefits source onto the mocked dependencies. * @@ -964,9 +1004,11 @@ describe('RewardsIntegrationService', () => { it('reads benefits through the SubscriptionController action when one is registered', async () => { const messengerBenefits = jest .fn() - .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })); + .mockResolvedValue( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); setupMessengerDefaults({ - 'SubscriptionController:getPerpsBenefits': messengerBenefits, + 'SubscriptionController:getBenefits': messengerBenefits, }); const diBenefits = wireSubscription( jest.fn().mockResolvedValue(createBenefits()), @@ -989,9 +1031,11 @@ describe('RewardsIntegrationService', () => { // made this configuration resolve `no-source` and never grant a waiver. const messengerBenefits = jest .fn() - .mockResolvedValue(createBenefits({ remainingNotionalUsd: 250 })); + .mockResolvedValue( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); setupMessengerDefaults({ - 'SubscriptionController:getPerpsBenefits': messengerBenefits, + 'SubscriptionController:getBenefits': messengerBenefits, }); ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock @@ -1020,9 +1064,11 @@ describe('RewardsIntegrationService', () => { // a waiver the user is still entitled to. const messengerBenefits = jest .fn() - .mockResolvedValueOnce(createBenefits({ remainingNotionalUsd: 250 })); + .mockResolvedValueOnce( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); setupMessengerDefaults({ - 'SubscriptionController:getPerpsBenefits': messengerBenefits, + 'SubscriptionController:getBenefits': messengerBenefits, }); ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock @@ -1048,6 +1094,41 @@ describe('RewardsIntegrationService', () => { expect(mockDeps.logger.error).toHaveBeenCalled(); }); + it('keeps a cached snapshot when a benefits handler throws synchronously', async () => { + // A handler that has answered before exists, so a synchronous throw is a + // real failure — not an unregistered action — and must not fall through + // to `null` and erase the cached waiver. + let failSynchronously = false; + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': () => { + if (failSynchronously) { + throw new Error('benefits handler exploded'); + } + return Promise.resolve( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); + }, + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + await service.refreshSubscriptionBenefits(); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + + failSynchronously = true; + jest.setSystemTime(NOW + FRESH_MS + 1); + await expect( + service.refreshSubscriptionBenefits(), + ).resolves.toBeUndefined(); + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 250, + }); + }); + it('still reports no source when neither wiring is present', async () => { setupMessengerDefaults(); ( @@ -1074,17 +1155,20 @@ describe('RewardsIntegrationService', () => { }); it('registers the trading address once per address and again after a reset', async () => { - const registerAddress = jest.fn().mockResolvedValue(undefined); - setupMessengerDefaults({ - 'SubscriptionController:registerAddress': registerAddress, - }); - wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + // `SubscriptionController` exposes no address-registration action, so this + // runs through the injected dependency until one exists. + const registerTradingAddress = jest.fn().mockResolvedValue(undefined); + setupMessengerDefaults(); + (mockDeps as { subscription?: unknown }).subscription = { + getPerpsBenefits: jest.fn().mockResolvedValue(null), + registerTradingAddress, + }; await service.registerTradingAddress(mockEvmAccount.address); await service.registerTradingAddress(mockEvmAccount.address); - expect(registerAddress).toHaveBeenCalledTimes(1); - expect(registerAddress).toHaveBeenCalledWith( + expect(registerTradingAddress).toHaveBeenCalledTimes(1); + expect(registerTradingAddress).toHaveBeenCalledWith( expect.stringMatching(/^eip155:1:0x/u), ); @@ -1092,32 +1176,12 @@ describe('RewardsIntegrationService', () => { service.resetRegisteredTradingAddresses(); await service.registerTradingAddress(mockEvmAccount.address); - expect(registerAddress).toHaveBeenCalledTimes(2); - }); - - it('registers the trading address for a client that wires only the messenger', async () => { - // The ADR-0064 configuration: SubscriptionController is registered and the - // legacy DI callback is not. Gating registration on the DI dependency - // would make it a silent no-op on exactly this client. - const registerAddress = jest.fn().mockResolvedValue(undefined); - setupMessengerDefaults({ - 'SubscriptionController:registerAddress': registerAddress, - }); - expect(mockDeps.subscription).toBeUndefined(); - - await service.registerTradingAddress(mockEvmAccount.address); - - expect(registerAddress).toHaveBeenCalledWith( - expect.stringMatching(/^eip155:1:0x/u), - ); + expect(registerTradingAddress).toHaveBeenCalledTimes(2); }); - it('never throws when address registration is unavailable', async () => { - setupMessengerDefaults({ - 'SubscriptionController:registerAddress': () => { - throw new Error('action not registered'); - }, - }); + it('skips address registration when the client cannot perform it', async () => { + // No injected registration hook: nothing to call, and nothing raised. + setupMessengerDefaults(); wireSubscription(jest.fn().mockResolvedValue(createBenefits())); await expect( @@ -1125,39 +1189,20 @@ describe('RewardsIntegrationService', () => { ).resolves.toBeUndefined(); }); - it('skips address registration when neither the messenger nor a source is wired', async () => { - // No SubscriptionController action registered and no DI source: the - // messenger call throws on the unregistered action and the catch absorbs - // it, so nothing is registered and nothing is raised. + it('never throws when address registration is unavailable', async () => { setupMessengerDefaults(); + (mockDeps as { subscription?: unknown }).subscription = { + getPerpsBenefits: jest.fn().mockResolvedValue(createBenefits()), + registerTradingAddress: jest + .fn() + .mockRejectedValue(new Error('address index unavailable')), + }; await expect( service.registerTradingAddress(mockEvmAccount.address), ).resolves.toBeUndefined(); - expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( - 'RewardsIntegrationService: Trading address registration skipped', - expect.objectContaining({ address: mockEvmAccount.address }), - ); }); - it('re-attempts registration once a SubscriptionController appears', async () => { - // Nothing handled the first attempt, so it must not be cached as done — - // otherwise a client that registers the action after the first preview - // never announces its address. - setupMessengerDefaults(); - await service.registerTradingAddress(mockEvmAccount.address); - - const registerAddress = jest.fn().mockResolvedValue(undefined); - setupMessengerDefaults({ - 'SubscriptionController:registerAddress': registerAddress, - }); - await service.registerTradingAddress(mockEvmAccount.address); - - expect(registerAddress).toHaveBeenCalledTimes(1); - }); - }); - - describe('instance isolation', () => { it('each instance uses its own deps', async () => { const mockDeps2 = createMockInfrastructure(); const mockMessenger2 = createMockMessenger(); diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index a195a8e59dd..a35f55783af 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -429,18 +429,29 @@ describe('applyFeeResolution', () => { ); }); - it('leaves the quote untouched when no source resolved', () => { + it('reprices to the undiscounted rate when the default source won', () => { + // `default` winning is a real answer of "no reduction". Returning the + // provider's own number instead would inherit whatever discount a + // concurrent order's submit last pushed into it. + const priced = applyFeeResolution({ + fees: { ...fees, metamaskFeeRate: 0.0005, feeRate: 0.00095 }, + resolution: { + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }); + + // The full 10-bip builder fee, not the 5-bip rate the provider carried. + expect(priced.metamaskFeeRate).toBeCloseTo(0.001, 10); + expect(priced.feeRate).toBeCloseTo(0.00145, 10); + }); + + it('leaves the quote untouched when no resolution was computed', () => { expect( - applyFeeResolution({ - fees, - resolution: { - feeBips: 10, - discountBips: undefined, - source: 'default', - subscription: createStatus({ eligible: false, reason: 'no-source' }), - }, - amount: '1000', - }), + applyFeeResolution({ fees, resolution: undefined, amount: '1000' }), ).toStrictEqual(fees); }); diff --git a/packages/perps-controller/tsconfig.build.json b/packages/perps-controller/tsconfig.build.json index c7d82da0dc9..fec2b6a3487 100644 --- a/packages/perps-controller/tsconfig.build.json +++ b/packages/perps-controller/tsconfig.build.json @@ -37,6 +37,9 @@ { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, + { + "path": "../subscription-controller/tsconfig.build.json" + }, { "path": "../transaction-controller/tsconfig.build.json" }, diff --git a/packages/perps-controller/tsconfig.json b/packages/perps-controller/tsconfig.json index 4f10ff12672..cd4bc34e56e 100644 --- a/packages/perps-controller/tsconfig.json +++ b/packages/perps-controller/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../remote-feature-flag-controller" }, + { + "path": "../subscription-controller" + }, { "path": "../transaction-controller" }, diff --git a/yarn.lock b/yarn.lock index 0f3e9cb12e2..60a74633591 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8461,6 +8461,7 @@ __metadata: "@metamask/network-controller": "npm:^37.0.0" "@metamask/profile-sync-controller": "npm:^32.1.1" "@metamask/remote-feature-flag-controller": "npm:^7.0.0" + "@metamask/subscription-controller": "npm:^9.0.1" "@metamask/superstruct": "npm:^3.4.1" "@metamask/transaction-controller": "npm:^70.1.0" "@metamask/utils": "npm:^12.0.0" From aeb0277fed1249c53826e2425359a220ca872cc9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 08:45:00 +0800 Subject: [PATCH 08/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reclassifies the messenger-union expansion as breaking. Messenger constrains a child's action union to be a subset of its parent's, so adding SubscriptionController:getBenefits to PerpsControllerAllowedActions forces every strict parent messenger type to add the action before it builds — including clients that never register the handler. Runtime behaviour for those clients is unchanged because the injected fallback still applies, but the build is not, and the changelog described this as additive. It is now a breaking entry with migration guidance to coordinate the client messenger updates. The messenger action docblock for approveSubscriptionBuilderFee still described the pre-ADR contract, promising that waivers fall back to the ordinary builder until approval succeeds. The controller method was deprecated and made a no-op two rounds ago; this second docblock was missed. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 3 ++- .../src/PerpsController-method-action-types.ts | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 6ea0444bb54..be4b07ed020 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,13 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. -- Add `SubscriptionController:getBenefits` to `PerpsControllerAllowedActions`, so benefits hydration runs over the action `@metamask/subscription-controller` already exposes. Its micro-USD allowances are converted to USD at the boundary. Clients that do not register the action keep using the injected `subscription` dependency. - Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. - Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID, and add an exact `./utils` subpath export so the barrel is importable as `@metamask/perps-controller/utils`. - Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. `SubscriptionController` exposes no address-registration action, so this runs through the injected dependency until one exists. ### Changed +- **BREAKING:** `PerpsControllerAllowedActions` now includes `SubscriptionController:getBenefits`, so benefits hydration can run over the action `@metamask/subscription-controller` already exposes. Its micro-USD allowances are converted to USD at the boundary. + - This is a type break for every client, including clients that do not register the action. `Messenger` requires each child action to exist in the parent action union, so a strict parent messenger type must add `SubscriptionControllerGetBenefitsAction` before it will build. Runtime behavior is unchanged for those clients — an unregistered action falls back to the injected `subscription` dependency — but the build does not pass without the type. Coordinate the Mobile and Extension messenger updates with this release. - **BREAKING:** `PerpsController.calculateFees` now quotes the subscription fee waiver as a blended rate derived from the order notional, so `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from previous releases when a subscription waiver applies. - Pass the order notional (USD) as `FeeCalculationParams.amount`. It is now required for quote/submit parity, and omitting it changes the quote rather than preserving the previous one: a waiver whose remaining allowance is bounded is withheld entirely from a quote with no notional, so the preview reports the next-lowest source while a submit that can derive a notional still applies the waiver. A waiver with no reported allowance bound is unaffected. - Quoted rates are also repriced when rewards win, not only under a subscription waiver, and are quantized to the venue's tenths of a basis point so a quote equals the charged rate. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 6cb437da976..5f5dee4e02b 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -907,10 +907,12 @@ export type PerpsControllerCalculateFeesAction = { /** * Approve the dedicated subscription builder outside order submission. - * Until this succeeds, subscription waivers fall back to the ordinary - * builder at the standard fee. * - * @returns Whether the subscription builder is approved. + * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid + * marking on the standard builder, so nothing needs approving and the handler + * is a no-op that always resolves `true`. Retained so callers keep building + * while they migrate; remove once cloid marking is verified in shadow mode. + * @returns Always `true`; no approval is required. */ export type PerpsControllerApproveSubscriptionBuilderFeeAction = { type: `PerpsController:approveSubscriptionBuilderFee`; From e56a4b6caa811b35ec595720615a80d4c3800d22 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 09:02:26 +0800 Subject: [PATCH 09/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects, three of them introduced by earlier rounds of this branch. A UserNotSubscribed rejection was treated as a failed read and preserved the cached snapshot. It is a definitive answer — SubscriptionController throws it when entitlement has ended and clears its own benefits state on the same path — so preserving the snapshot kept granting the waiver for the rest of the ten-minute staleness window after the user stopped paying. It now resolves to null, which replaces the snapshot, while every other failure still preserves it. An order edit marked its replacement client order ID as fee-reduced, two lines below a comment recording that HyperLiquid's modify action carries no builder field. No MetaMask fee is charged on that action, so the marking reported a reduction on an order that paid nothing. The replacement now inherits the resting order's attribution instead. A trigger placement could not be priced: the notional resolver consulted the limit price, the caller's snapshot and the live quote, but not triggerPrice, which is the only price a stop or take-profit placement carries. Bounded allowances fail closed, so such an order silently lost the waiver. A fee preview read the subscription status separately from the resolution that produces its rates, so an invalidation or feature-flag change between the two could attach metadata describing a waiver the rates did not reflect. Also regenerates PerpsController-method-action-types.ts, which a previous commit hand-edited even though it is generated, leaving messenger-action-types:check failing. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 4 +++ .../PerpsController-method-action-types.ts | 11 ++++-- .../perps-controller/src/PerpsController.ts | 7 ++-- .../src/providers/HyperLiquidProvider.ts | 9 +++-- .../src/services/RewardsIntegrationService.ts | 34 +++++++++++++++++++ .../src/services/TradingService.ts | 16 ++++++++- .../HyperLiquidProvider.trading.test.ts | 31 ++++++++++------- .../RewardsIntegrationService.test.ts | 32 +++++++++++++++++ .../tests/src/services/TradingService.test.ts | 24 +++++++++++++ 9 files changed, 146 insertions(+), 22 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index be4b07ed020..538f0fc0994 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -41,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. - Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. - Preserve a cached benefits snapshot when a registered `SubscriptionController:getBenefits` handler rejects or throws synchronously and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. +- Drop a cached subscription waiver when `SubscriptionController:getBenefits` reports the profile is not subscribed. That rejection is a definitive answer rather than a failed read — the controller clears its own benefits state on the same path — so preserving the cached snapshot kept granting the waiver for the rest of the staleness window after entitlement ended. +- Stop marking the client order ID of an order edit. HyperLiquid's `modify` action carries no builder field, so no MetaMask fee is charged on it, and marking reported a fee reduction on an order that paid nothing. The replacement inherits the resting order's own attribution. +- Price a trigger placement (stop or take-profit) from its trigger price. Such an order carries no limit price, so its notional could not be derived and a bounded waiver was withheld from an order the provider prices later. +- Read the subscription metadata attached to a fee preview from the same resolution as the quoted rates. They were two separate reads, so a cache invalidation or feature-flag change between them could return metadata describing a waiver the rates did not reflect. - Reprice a fee preview to the undiscounted builder fee when the default source wins, rather than returning the provider's own rate. The provider's rate reflects the discount the last submit pushed into it, so a concurrent order could leak its discount into an unrelated quote. - Price a take-profit/stop-loss update from the position read back through the routed provider when the caller supplies neither a position snapshot nor tracking data. Both are optional, and a bounded waiver is withheld without a notional, so a valid update silently lost the waiver. - Price a close from the position the write can actually reach. A close read positions across every active provider and matched on symbol alone, so in aggregated mode a batch close summed positions it could not close, and a routed single close could price another provider's position for the same symbol. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 5f5dee4e02b..7c0e6daaa3e 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -909,9 +909,14 @@ export type PerpsControllerCalculateFeesAction = { * Approve the dedicated subscription builder outside order submission. * * @deprecated ADR 0064 replaced the dedicated subscription builder with cloid - * marking on the standard builder, so nothing needs approving and the handler - * is a no-op that always resolves `true`. Retained so callers keep building - * while they migrate; remove once cloid marking is verified in shadow mode. + * marking on the standard builder, so there is nothing left to approve. Kept + * as a no-op so clients still calling it keep building while they migrate; + * remove it once cloid marking is verified in shadow mode. + * + * Resolves `true`, not `false`. The method answers "is the subscription + * builder ready?", and the honest answer is now "nothing needs approving" — + * a `false` would read as a setup failure to a caller that branches on it and + * could block a waiver that is already fully in effect. * @returns Always `true`; no approval is required. */ export type PerpsControllerApproveSubscriptionBuilderFeeAction = { diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 02912be90d6..d5a35a5c173 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5823,8 +5823,6 @@ export class PerpsController extends BaseController< }); } - const waiverStatus = - this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); // The preview quotes the same blended rate the submit path charges, which // is only possible once the order notional reaches the resolver. `amount` // is the order notional in USD for the quote being previewed. @@ -5833,6 +5831,11 @@ export class PerpsController extends BaseController< : undefined; const feeResolution = await this.#rewardsIntegrationService.resolveFee(orderNotionalUsd); + // Taken from the resolution rather than read separately: a second read can + // observe a different snapshot if the cache is invalidated or the feature + // flag flips between the two, which would surface metadata describing a + // waiver the quoted rates do not reflect. + const waiverStatus = feeResolution.subscription; const context = this.#createServiceContext('calculateFees', { subscriptionFeeWaiver: waiverStatus.reason === 'no-source' ? undefined : waiverStatus, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 2ce0036bd4f..e491fc53728 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -8976,15 +8976,18 @@ export class HyperLiquidProvider implements PerpsProvider { // builder-fee approval. await this.#ensureReadyForTrading({ requiresBuilderFee: false }); - // Submit modification via SDK + // Submit modification via SDK. The cloid is deliberately left unmarked: + // `modify` carries no builder field, as the readiness call above records, + // so no MetaMask fee is charged on this action and marking it would tell + // the fill fan-out a reduction applied to an order that paid nothing. + // The replacement inherits the resting order's own attribution. const exchangeClient = this.#clientService.getExchangeClient(); - const [markedNewOrder] = this.#applySubscriptionCloid([newOrder]); const result = await exchangeClient.modify({ oid: typeof params.orderId === 'string' ? (params.orderId as Hex) : params.orderId, - order: markedNewOrder, + order: newOrder, }); if (result.status !== 'ok') { diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 363d64e7627..101de78b5c8 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -494,6 +494,10 @@ export class RewardsIntegrationService { // real failure rather than an unregistered action. Treating it as the // latter would fall through to `null` and erase a valid cached snapshot, // exactly as an asynchronous rejection would. + if (isNotSubscribedRejection(error)) { + // Definitive "not entitled", even thrown synchronously. + return null; + } if (this.#messengerBenefitsAnswered && !this.#deps.subscription) { throw error; } @@ -507,6 +511,13 @@ export class RewardsIntegrationService { try { return await pending; } catch (error) { + if (isNotSubscribedRejection(error)) { + // A definitive answer, not a failed read: the profile is not + // entitled. Returning `null` replaces the cached snapshot, which is + // the point — preserving it would keep granting the waiver for the + // rest of the staleness window after entitlement ended. + return null; + } if (!fallback) { // Nothing else can answer, so this rejection is the whole result. // Returning `null` here would be stored as a successful "no @@ -721,6 +732,29 @@ export class RewardsIntegrationService { } } +/** + * Whether a benefits rejection definitively means "this profile is not + * entitled", as opposed to "the read failed". + * + * `SubscriptionController.getBenefits` throws `UserNotSubscribed` when the + * subscription is inactive or the response reports ineligibility, and clears + * its own benefits state on that path. Treating it as a transport failure would + * keep serving a cached waiver for the rest of the staleness window — up to ten + * minutes of free trading after entitlement ended — so it is converted to a + * real `null` answer instead. + * + * Matched on the message because the controller throws a plain `Error`; any + * other failure stays a failure and preserves the cached snapshot. + * + * @param error - The rejection from the benefits read. + * @returns True when the rejection means the profile is not entitled. + */ +function isNotSubscribedRejection(error: unknown): boolean { + return ( + error instanceof Error && error.message.includes('User is not subscribed') + ); +} + /** * Convert `SubscriptionController`'s benefits response into the shape the * waiver gate reads. diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 90455b378ab..889b27af342 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1319,6 +1319,8 @@ export class TradingService { * @param params.size - Order size in base units, when known. * @param params.usdAmount - Order notional in USD, when the caller supplied it. * @param params.price - Limit price, when the placement carries one. + * @param params.triggerPrice - Trigger level, for a placement that has no + * limit price of its own. * @param params.currentPrice - Live market price the order was quoted against. * @param params.priceAtCalculation - Price snapshot taken when size was derived. * @returns The order notional in USD, or undefined when it cannot be priced. @@ -1327,6 +1329,7 @@ export class TradingService { size?: string; usdAmount?: string; price?: string; + triggerPrice?: string; currentPrice?: number; priceAtCalculation?: number; }): number | undefined { @@ -1350,7 +1353,18 @@ export class TradingService { const limitPrice = params.price === undefined ? undefined : Number.parseFloat(params.price); - const price = [limitPrice, params.priceAtCalculation, params.currentPrice] + // A trigger placement carries no limit price; the level it activates at is + // the only price it states, so it prices the order. + const triggerPrice = + params.triggerPrice === undefined + ? undefined + : Number.parseFloat(params.triggerPrice); + const price = [ + limitPrice, + triggerPrice, + params.priceAtCalculation, + params.currentPrice, + ] .filter( (candidate): candidate is number => candidate !== undefined && diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 38143dd4608..7ae9ad787b2 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -4906,21 +4906,26 @@ describe('HyperLiquidProvider', () => { } as OrderParams, }; - it('marks the cloid with the subscription program id when subscription wins', async () => { - provider.setUserFeeResolution(subscriptionResolution); + it('leaves the replacement cloid unmarked because modify charges no builder fee', () => { + // `modify` carries no builder field, so no MetaMask fee is charged on the + // action. Marking it would report a reduction on an order that paid + // nothing; the replacement inherits the resting order's attribution. + return (async () => { + provider.setUserFeeResolution(subscriptionResolution); - const result = await provider.editOrder(editParams); + const result = await provider.editOrder(editParams); - expect(result.success).toBe(true); - const modifyCalls = ( - mockClientService.getExchangeClient().modify as jest.Mock - ).mock.calls; - expect(modifyCalls.length).toBeGreaterThan(0); - modifyCalls.forEach(([payload]) => { - const cloid = (payload as { order: { c?: string } }).order.c; - expect(hasFeeReductionAppliedFlag(cloid)).toBe(true); - expect(isSubscriptionProgramCloid(cloid)).toBe(true); - }); + expect(result.success).toBe(true); + const modifyCalls = ( + mockClientService.getExchangeClient().modify as jest.Mock + ).mock.calls; + expect(modifyCalls.length).toBeGreaterThan(0); + modifyCalls.forEach(([payload]) => { + const cloid = (payload as { order: { c?: string } }).order.c; + expect(hasFeeReductionAppliedFlag(cloid)).toBe(false); + expect(isSubscriptionProgramCloid(cloid)).toBe(false); + }); + })(); }); it('marks the cloid on the batch close path', async () => { diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 5edcfad567c..0a40f3426bc 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1129,6 +1129,38 @@ describe('RewardsIntegrationService', () => { }); }); + it('drops a cached waiver when the profile is definitively not subscribed', async () => { + // `UserNotSubscribed` is an answer, not an outage: the controller clears + // its own benefits state on that path. Preserving the cached snapshot + // would keep granting the waiver for the rest of the staleness window + // after entitlement ended. + const messengerBenefits = jest + .fn() + .mockResolvedValueOnce( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': messengerBenefits, + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + await service.refreshSubscriptionBenefits(); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + + messengerBenefits.mockRejectedValue( + new Error('SubscriptionController - User is not subscribed'), + ); + jest.setSystemTime(NOW + FRESH_MS + 1); + await service.refreshSubscriptionBenefits(); + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'no-subscription', + }); + }); + it('still reports no source when neither wiring is present', async () => { setupMessengerDefaults(); ( diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 3e08a338225..b062a5c14f7 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -248,6 +248,30 @@ describe('TradingService', () => { ); }); + it('prices a trigger placement from its trigger price', async () => { + // A stop/take-profit placement carries no limit price; the level it + // activates at is the only price it states, and without it a bounded + // waiver is withheld on an order the provider can price later. + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'stop_market', + triggerPrice: '50000', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 1000, + ); + }); + it('still resolves a fee when the order cannot be priced', async () => { mockProvider.placeOrder.mockResolvedValue({ success: true }); From e88eb7da3ee9b524127624351cf08f74ca226fe4 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 09:29:09 +0800 Subject: [PATCH 10/21] fix: address self-review feedback (TAT-3967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports the trading-address registration gap instead of returning silently. A client that adopts SubscriptionController over the messenger but injects no registerTradingAddress hook cannot register an address at all — no such messenger action exists — so its fills arrive unattributed with nothing to point at. Calling an action nothing answers would be worse, so the early return stays, but it now logs and the JSDoc states the consequence rather than only explaining the design. Removes a stale paragraph above SUBSCRIPTION_CLOID_CONFIG that still called the program id a placeholder pending the registry, directly above the docblock describing the registered value and its encoding. Narrows the recipe decision's AC5 claim, which asserted that address registration runs via the SubscriptionController integration — the precise thing AC5 is recorded PARTIAL for not doing. Co-Authored-By: Claude Opus 5 --- .../perps-controller/src/constants/perpsConfig.ts | 7 +++---- .../src/services/RewardsIntegrationService.ts | 15 +++++++++++++++ .../services/RewardsIntegrationService.test.ts | 10 ++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 3ea091d4c60..09ed6ae2f4b 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -433,10 +433,9 @@ export const SUBSCRIPTION_BENEFITS_CACHE = { * keeps its own `4d4d5343` marker and its rung index, and only the flag byte is * claimed, leaving group recovery and cancel-by-cloid intact. * - * `ProgramId` is a placeholder. The registry value is an open `[TODO]` in ADR - * 0064 and belongs to the cloid schema owners, so it is deliberately isolated - * in this one constant: adopting the real value is a one-line change and every - * marking/decoding path already reads it from here. + * `ProgramId` is isolated in this one constant so every marking and decoding + * path reads the marker from a single place; see its own documentation below + * for the registered value and how it is encoded. */ export const SUBSCRIPTION_CLOID_CONFIG = { /** diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 101de78b5c8..77ec2996e9d 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -553,12 +553,27 @@ export class RewardsIntegrationService { * client supplies one. Wiring it to a messenger action is left until that * action exists rather than calling a name nothing answers. * + * **A messenger-only client therefore registers nothing.** Benefits hydration + * works over `SubscriptionController:getBenefits`, but a client that adopts + * only the messenger and injects no `registerTradingAddress` hook gets no + * address registration at all, and its fills cannot be attributed to a + * profile. That is a wiring gap rather than a failure, so it is logged rather + * than raised; supplying the hook — or a registration action, once one exists + * — is what closes it. + * * @param address - The EVM trading address to register. * @returns A promise that resolves once the attempt settles. */ async registerTradingAddress(address: string): Promise { const source = this.#deps.subscription; if (!source?.registerTradingAddress) { + // Visible rather than silent: a client wired only to the messenger has no + // way to register, and a missing registration is otherwise indetectable + // until fills arrive unattributed. + this.#deps.debugLogger.log( + 'RewardsIntegrationService: No trading-address registration hook wired; fills will be unattributed', + { address }, + ); return; } diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 0a40f3426bc..c3aa40fbe75 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1211,14 +1211,20 @@ describe('RewardsIntegrationService', () => { expect(registerTradingAddress).toHaveBeenCalledTimes(2); }); - it('skips address registration when the client cannot perform it', async () => { - // No injected registration hook: nothing to call, and nothing raised. + it('reports the gap when the client has no registration hook', async () => { + // A messenger-only client hydrates benefits but cannot register an + // address, so its fills go unattributed. Nothing is raised — it is a + // wiring gap, not a failure — but it must not be silent either. setupMessengerDefaults(); wireSubscription(jest.fn().mockResolvedValue(createBenefits())); await expect( service.registerTradingAddress(mockEvmAccount.address), ).resolves.toBeUndefined(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: No trading-address registration hook wired; fills will be unattributed', + { address: mockEvmAccount.address }, + ); }); it('never throws when address registration is unavailable', async () => { From 11466c8e96abcc9f946b70cabc0acaf4b0f5e5e1 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 10:08:00 +0800 Subject: [PATCH 11/21] fix: address self-review feedback (TAT-3967) Corrects the resolveFee documentation, which still described the fail-open behaviour a previous round replaced. It said a caller with no order notional receives the full-waiver rate; that holds only when the backend reported no allowance bound. A bounded allowance is withheld in that case, deliberately, so an order of unknown size cannot silently spend the cap. The docblock now distinguishes the two. Co-Authored-By: Claude Opus 5 --- .../src/services/RewardsIntegrationService.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 77ec2996e9d..9e9f9d17797 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -167,9 +167,14 @@ export class RewardsIntegrationService { * The subscription source contributes an effective rate rather than a flat * zero (ADR 0064): the allowance may cover only part of the order, and the * blend that results has to be able to lose to a deeper VIP or season - * discount. Passing the order notional is what makes that blend possible; a - * caller with no notional to quote against (a rate-only preview) gets the - * full-waiver rate, which is the pre-ADR behavior. + * discount. Passing the order notional is what makes that blend possible. + * + * Without a notional the outcome depends on whether the backend bounded the + * allowance. An unbounded allowance still resolves to the full waiver — there + * is no cap to over-consume. A *bounded* one is withheld entirely rather than + * quoted as a full waiver, because charging nothing on an order of unknown + * size would silently spend the cap; such a caller gets the next-lowest + * source instead. * * @param orderNotionalUsd - Order notional (USD), when the caller knows it. * @returns The winning fee, its source, and the subscription gate outcome. From 21c4ead75e00cbcacc236bc96b9c6d1fda007aeb Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 10:44:11 +0800 Subject: [PATCH 12/21] fix: address self-review feedback (TAT-3967) Four defects in code paths the suite did not exercise. products.perps is always present on the benefits response, so testing its existence proved nothing about entitlement: a profile eligible for other products, whose perps block carried no builder fee, no allowance and no cap, was granted an unbounded full waiver. Entitlement now requires positive evidence from the perps block itself. The cloid decoder validated length and prefix but not that the id was hex. parseInt('1z', 16) is 1, so a client order ID with a partly-hex flag byte reported whichever flags its leading digit encoded. The whole id is now matched against a hex pattern. applyFeeResolution accepted any finite amount, so a negative notional produced negative feeAmount and metamaskFeeAmount. A non-positive value is not an order size; rates are still re-priced but the amounts are left as the provider reported them. A synchronous benefits-handler failure on the very first call was indistinguishable from an unregistered action, because the distinction rested on whether a call had previously succeeded, and its null was cached as a successful "no subscription" answer. The distinction is now made on the error itself. Also repairs a docblock left mangled by an earlier insertion. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 4 ++ .../src/services/RewardsIntegrationService.ts | 39 ++++++++++- .../src/utils/subscriptionFeeWaiver.ts | 18 +++-- .../RewardsIntegrationService.test.ts | 69 +++++++++++++++++++ .../src/utils/subscriptionFeeWaiver.test.ts | 33 +++++++++ 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 538f0fc0994..c49be1dbc86 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -45,6 +45,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stop marking the client order ID of an order edit. HyperLiquid's `modify` action carries no builder field, so no MetaMask fee is charged on it, and marking reported a fee reduction on an order that paid nothing. The replacement inherits the resting order's own attribution. - Price a trigger placement (stop or take-profit) from its trigger price. Such an order carries no limit price, so its notional could not be derived and a bounded waiver was withheld from an order the provider prices later. - Read the subscription metadata attached to a fee preview from the same resolution as the quoted rates. They were two separate reads, so a cache invalidation or feature-flag change between them could return metadata describing a waiver the rates did not reflect. +- Require positive evidence of a perps benefit before granting the waiver. `products.perps` is always present on the benefits response, so its existence proved nothing: a profile eligible for other products but carrying no perps builder fee, allowance or cap was granted an unbounded full waiver. +- Reject a malformed client order ID in `hasFeeReductionAppliedFlag` and `readSubscriptionCloidFlags`. Only length and prefix were validated, so a flag byte such as `1z` parsed as `1` and reported the order as fee-reduced. +- Leave quoted fee amounts untouched when the supplied order notional is zero or negative. Such a value is not an order size and previously produced negative `feeAmount` and `metamaskFeeAmount` figures. +- Distinguish an unregistered benefits action from a handler that throws on its first call. The latter was treated as the former and cached as a successful "no subscription" result. - Reprice a fee preview to the undiscounted builder fee when the default source wins, rather than returning the provider's own rate. The provider's rate reflects the discount the last submit pushed into it, so a concurrent order could leak its discount into an unrelated quote. - Price a take-profit/stop-loss update from the position read back through the routed provider when the caller supplies neither a position snapshot nor tracking data. Both are optional, and a bounded waiver is withheld without a notional, so a valid update silently lost the waiver. - Price a close from the position the write can actually reach. A close read positions across every active provider and matched on symbol alone, so in aggregated mode a batch close summed positions it could not close, and a routed single close could price another provider's position for the same symbol. diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 9e9f9d17797..61af2af1a58 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -503,7 +503,11 @@ export class RewardsIntegrationService { // Definitive "not entitled", even thrown synchronously. return null; } - if (this.#messengerBenefitsAnswered && !this.#deps.subscription) { + // An unregistered action throws a recognisable "no handler" error; any + // other synchronous throw came from a handler that exists and failed, so + // it must not be mistaken for an absent action and cached as `null` — + // including on the very first call, before one has ever answered. + if (!isUnregisteredActionError(error) && !this.#deps.subscription) { throw error; } // Otherwise: unregistered action, or a throw with a DI source to fall @@ -752,6 +756,25 @@ export class RewardsIntegrationService { } } +/** + * Whether a messenger call failed because no handler is registered. + * + * `Messenger.call` reports an unregistered action with a distinctive message. + * Anything else thrown synchronously came from a handler that does exist, and + * conflating the two would cache a real failure as "no subscription". + * + * @param error - The error thrown by the messenger call. + * @returns True when the action has no registered handler. + */ +function isUnregisteredActionError(error: unknown): boolean { + return ( + error instanceof Error && + /handler.*not.*registered|no.*handler.*registered|A handler for .* has not been registered/iu.test( + error.message, + ) + ); +} + /** * Whether a benefits rejection definitively means "this profile is not * entitled", as opposed to "the read failed". @@ -799,11 +822,22 @@ function adaptSubscriptionBenefits( } const perps = response.products?.perps; + // `products.perps` is always present on the response, so its existence proves + // nothing. Entitlement is the response-level `eligible` flag plus positive + // evidence from the perps block itself: a builder fee rate to apply, or a + // reported allowance to spend. A block carrying neither describes a profile + // with no perps benefit, whatever the other products say. + const hasPerpsBenefit = Boolean( + perps && + (perps.builderFeeBips !== null || + perps.remainingMicroUsd !== null || + perps.capMicroUsd !== undefined), + ); return { status: response.eligible ? 'active' : 'inactive', perpsFeeWaiver: { - entitled: response.eligible && Boolean(perps), + entitled: response.eligible && hasPerpsBenefit, usage: perps?.exhausted ? 'exhausted' : 'available', exhausted: perps?.exhausted, remainingNotionalUsd: @@ -816,7 +850,6 @@ function adaptSubscriptionBenefits( } /** - * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot./** * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot. * * The gate is `status=active` AND `perpsFeeWaiver` entitled AND diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 2b557762b0e..ace2bf25810 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -139,6 +139,9 @@ const FLAG_BYTE_START = 2 + SUBSCRIPTION_CLOID_CONFIG.ProgramIdHexLength; /** Full length of a venue cloid string: `0x` plus 16 bytes of hex. */ const CLOID_HEX_LENGTH = 34; +/** A well-formed venue cloid: `0x` followed by exactly 32 hex characters. */ +const CLOID_PATTERN = /^0x[0-9a-f]{32}$/u; + /** * Read the flag byte out of a cloid. * @@ -149,14 +152,16 @@ export function readSubscriptionCloidFlags( clientOrderId: string | null | undefined, ): number | undefined { const normalized = clientOrderId?.toLowerCase(); - if (normalized?.length !== CLOID_HEX_LENGTH) { + // The whole id must be well-formed hex, not merely the right length: a byte + // like `1z` parses as 1 under `parseInt`, so a malformed id would otherwise + // report whichever flags its leading digit happens to encode. + if (normalized === undefined || !CLOID_PATTERN.test(normalized)) { return undefined; } - const flags = Number.parseInt( + return Number.parseInt( normalized.slice(FLAG_BYTE_START, FLAG_BYTE_START + 2), 16, ); - return Number.isNaN(flags) ? undefined : flags; } /** @@ -390,8 +395,13 @@ export function applyFeeResolution(params: { quantizeBuilderFeeTenthsBps(discountBips) / BUILDER_FEE_TENTHS_BPS_PER_UNIT; const parsedAmount = amount === undefined ? undefined : Number.parseFloat(amount); + // A non-positive notional is not an order size, and recomputing from it would + // quote a negative fee. The rates are still re-priced; only the amounts are + // left as the provider reported them. const notional = - parsedAmount !== undefined && Number.isFinite(parsedAmount) + parsedAmount !== undefined && + Number.isFinite(parsedAmount) && + parsedAmount > 0 ? parsedAmount : undefined; diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index c3aa40fbe75..7df8fb376bb 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1161,6 +1161,75 @@ describe('RewardsIntegrationService', () => { }); }); + it('withholds the waiver when the perps block reports no benefit', async () => { + // `products.perps` is always present on the response, so its existence + // proves nothing. A block with no builder fee, no allowance and no cap + // describes a profile with no perps benefit even when the response is + // eligible for other products. + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': jest.fn().mockResolvedValue( + createBenefitsResponse({ + builderFeeBips: null, + remainingMicroUsd: null, + capMicroUsd: undefined, + }), + ), + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + await service.refreshSubscriptionBenefits(); + + expect(service.getSubscriptionFeeWaiverStatus()).toMatchObject({ + eligible: false, + reason: 'not-entitled', + }); + }); + + it('keeps the waiver when the perps block reports a builder fee but no cap', async () => { + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': jest.fn().mockResolvedValue( + createBenefitsResponse({ + builderFeeBips: '0', + remainingMicroUsd: null, + }), + ), + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + await service.refreshSubscriptionBenefits(); + + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + }); + + it('does not cache a first-call handler failure as no subscription', async () => { + // Before this fix a synchronous throw on the very first call looked like + // an unregistered action, so a real handler failure was stored as a + // successful "no subscription" result. + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': () => { + throw new Error('benefits handler exploded'); + }, + }); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + expect(mockDeps.subscription).toBeUndefined(); + + await expect( + service.refreshSubscriptionBenefits(), + ).resolves.toBeUndefined(); + + // Not hydrated, rather than a cached "no subscription" answer. + expect(service.getSubscriptionFeeWaiverStatus().reason).not.toBe( + 'no-subscription', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + it('still reports no source when neither wiring is present', async () => { setupMessengerDefaults(); ( diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index a35f55783af..28b88f5821f 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -345,6 +345,16 @@ describe('hasFeeReductionAppliedFlag', () => { expect(legacyRungs.filter(hasFeeReductionAppliedFlag)).toStrictEqual([]); }); + it('rejects a malformed cloid whose flag byte is only partly hex', () => { + // `parseInt('1z', 16)` is 1, so a length-and-prefix check alone would + // report whichever flags the leading digit happens to encode. + const malformed = `0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}1z${'a'.repeat(22)}`; + + expect(malformed).toHaveLength(34); + expect(readSubscriptionCloidFlags(malformed)).toBeUndefined(); + expect(hasFeeReductionAppliedFlag(malformed)).toBe(false); + }); + it('reports no flag for an unmarked Scale cloid, whose flag byte is reserved', () => { // The Scale generator zeroes the byte the subscription flag lives in, so // an unmarked ladder can never decode downstream as a waived one. @@ -449,6 +459,29 @@ describe('applyFeeResolution', () => { expect(priced.feeRate).toBeCloseTo(0.00145, 10); }); + it.each(['-1000', '0'])( + 'leaves quoted amounts alone for a non-positive notional of %p', + (amount) => { + // A non-positive notional is not an order size; recomputing from it would + // quote a negative fee. The rates are still re-priced. + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 7.5, + discountBips: 2500, + source: 'subscription', + subscription: createStatus({ remainingNotionalUsd: 250 }), + subscriptionWaiverKind: 'partial', + }, + amount, + }); + + expect(priced.metamaskFeeRate).toBeCloseTo(0.00075, 10); + expect(priced.feeAmount).toBe(fees.feeAmount); + expect(priced.metamaskFeeAmount).toBe(fees.metamaskFeeAmount); + }, + ); + it('leaves the quote untouched when no resolution was computed', () => { expect( applyFeeResolution({ fees, resolution: undefined, amount: '1000' }), From e2d25c35037c85c04f3442c1a0b29eaac289221c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 14:29:16 +0800 Subject: [PATCH 13/21] fix: address self-review feedback (TAT-3967) Report source: 'subscription' only when the waiver survives the venue's quantization of the builder fee to tenths of a basis point. A blend just under the default rounds to the same charge, so such an order was labelled subscription-sourced with a 0 bips discount while paying full price, and disagreed with the client order ID, which already withholds its marking there. Distinguish a metamaskFeeRate of 0 that means "this placement carries no builder fee" from the 0 a concurrent fully waived submit leaves in provider state, via a new chargesMetamaskBuilderFee field on FeeCalculationResult. An ordinary preview racing such a submit previously inherited its waiver. Reject non-hex values from isSubscriptionProgramCloid, which matched on length and prefix alone although it gates the decoder. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 4 ++ .../src/providers/HyperLiquidProvider.ts | 1 + .../src/providers/LighterProvider.ts | 2 + .../src/services/MarketDataService.ts | 1 + .../src/services/RewardsIntegrationService.ts | 31 ++++++++++++- packages/perps-controller/src/types/index.ts | 11 +++++ .../src/utils/subscriptionFeeWaiver.ts | 28 ++++++++---- .../RewardsIntegrationService.test.ts | 45 +++++++++++++++++++ .../src/utils/subscriptionFeeWaiver.test.ts | 34 ++++++++++++++ 9 files changed, 147 insertions(+), 10 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c49be1dbc86..c6057729099 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. - Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. - Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID, and add an exact `./utils` subpath export so the barrel is importable as `@metamask/perps-controller/utils`. +- Add an optional `chargesMetamaskBuilderFee` field to `FeeCalculationResult`, which reports whether a placement can carry a MetaMask builder fee at all. A `metamaskFeeRate` of `0` is otherwise ambiguous between a venue or order type that has no builder field and a fully waived fee. - Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. `SubscriptionController` exposes no address-registration action, so this runs through the injected dependency until one exists. ### Changed @@ -36,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. +- Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. +- Reject client order IDs that match the subscription program marker and length but contain non-hex characters from `isSubscriptionProgramCloid`, which no longer treats such a value as a marked ID. - Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. - Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. - Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index e491fc53728..265b1b7b764 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -14717,6 +14717,7 @@ export class HyperLiquidProvider implements PerpsProvider { // MetaMask fees metamaskFeeRate, metamaskFeeAmount, + chargesMetamaskBuilderFee, }; this.#deps.debugLogger.log('Final Fee Calculation Result', { diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index e16168741e4..36841a231b5 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -7534,6 +7534,8 @@ export class LighterProvider implements PerpsProvider { feeAmount: Number.isFinite(amount) ? amount * feeRate : 0, protocolFeeRate: feeRate, metamaskFeeRate: 0, + // Structurally zero on this venue, not a waiver applied to a real fee. + chargesMetamaskBuilderFee: false, }; } diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 66ddf4fadca..381c2137892 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1348,6 +1348,7 @@ export class MarketDataService { fees, resolution: context.feeResolution, amount: params.amount, + chargesNoBuilderFee: fees.chargesMetamaskBuilderFee === false, }); // Read-only preview of the same cached benefits snapshot the fee resolver diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 61af2af1a58..51edd72fef3 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -20,7 +20,10 @@ import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; import { ensureError } from '../utils/errorUtils.js'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.js'; -import { resolveSubscriptionWaiverRate } from '../utils/subscriptionFeeWaiver.js'; +import { + quantizeBuilderFeeTenthsBps, + resolveSubscriptionWaiverRate, +} from '../utils/subscriptionFeeWaiver.js'; /** * Default MetaMask builder fee, in basis points. @@ -212,7 +215,31 @@ export class RewardsIntegrationService { // `<=`, but a partial blend only wins when it is genuinely cheaper than the // rewards discount — the ADR's requirement that subscription be able to // lose. - if (waiver.applies && waiver.feeBips <= feeBips) { + // + // Compared *after* venue quantization, because that is the fee the order + // pays. The builder fee is submitted in integer tenths of a basis point, so + // a blend like 9.9999 bips is cheaper than the 10-bip default in arithmetic + // and identical to it on the wire. Selecting subscription on the raw number + // would label such an order `source: 'subscription'` with a 0-bip discount + // while it pays full price, and would disagree with the cloid marker, which + // already gates on the quantized fee. + const toTenthsBps = (bips: number): number => + quantizeBuilderFeeTenthsBps( + Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR), + ); + + // A strictly cheaper raw blend must also be cheaper once quantized. A tie + // on the raw number is exempt: matching an already-free rate still consumes + // the allowance, so subscription is the honest source there. + const waiverSurvivesQuantization = + waiver.feeBips === feeBips || + toTenthsBps(waiver.feeBips) < toTenthsBps(feeBips); + + if ( + waiver.applies && + waiver.feeBips <= feeBips && + waiverSurvivesQuantization + ) { feeBips = waiver.feeBips; source = 'subscription'; subscriptionWaiverKind = waiver.kind === 'partial' ? 'partial' : 'full'; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7b123da6d16..112dcb61f85 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1687,6 +1687,17 @@ export type FeeCalculationResult = { metamaskFeeRate?: number; // MetaMask fee rate (e.g., 0.001 for 0.1%), undefined when unavailable metamaskFeeAmount?: number; // MetaMask fee amount in USD + /** + * Whether this placement can carry a MetaMask builder fee at all. + * + * A `metamaskFeeRate` of zero is ambiguous on its own: it is what a venue or + * order type that has no builder field reports (`false` here), and also what + * a fully waived discount leaves behind (`true` here). Only the provider + * knows which, so it says so rather than leaving callers to guess from the + * number. Absent when the provider does not report a policy. + */ + chargesMetamaskBuilderFee?: boolean; + // Optional detailed breakdown for transparency breakdown?: { baseFeeRate: number; diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index ace2bf25810..8f2b3572194 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -238,9 +238,13 @@ export function isSubscriptionProgramCloid( clientOrderId: string | null | undefined, ): boolean { const normalized = clientOrderId?.toLowerCase(); + // Hex-validated for the same reason the flag reader is: a length-and-prefix + // check would accept an id whose remaining bytes are not hex at all, and this + // predicate is what gates the decoder. return Boolean( - normalized?.length === CLOID_HEX_LENGTH && - normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`), + normalized && + CLOID_PATTERN.test(normalized) && + normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`), ); } @@ -366,20 +370,28 @@ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { * @param params.fees - The provider's fee quote. * @param params.resolution - The unified fee resolution, when one was computed. * @param params.amount - Order notional (USD) as a string, when provided. + * @param params.chargesNoBuilderFee - True when this placement carries no + * MetaMask builder fee at all (a TWAP, for instance). Distinguishes a genuine + * zero from the zero a concurrent fully-waived submit leaves in provider state. * @returns The quote with its MetaMask component and totals re-priced. */ export function applyFeeResolution(params: { fees: FeeCalculationResult; resolution: PerpsFeeResolution | undefined; amount?: string; + chargesNoBuilderFee?: boolean; }): FeeCalculationResult { - const { fees, resolution, amount } = params; + const { fees, resolution, amount, chargesNoBuilderFee = false } = params; - if ( - resolution === undefined || - fees.metamaskFeeRate === undefined || - fees.metamaskFeeRate === 0 - ) { + if (resolution === undefined || fees.metamaskFeeRate === undefined) { + return fees; + } + + // A provider rate of zero is not proof that this placement carries no builder + // fee: it is also what a concurrent fully-waived submit leaves behind in + // provider state. Distinguish the two by asking the policy, not the leftover + // number — otherwise an ordinary preview inherits someone else's waiver. + if (fees.metamaskFeeRate === 0 && chargesNoBuilderFee) { return fees; } diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 7df8fb376bb..b9a4bad94f7 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -456,6 +456,51 @@ describe('RewardsIntegrationService', () => { } }); + it('does not claim the subscription source when the blend quantizes to the full fee', async () => { + // A cent of allowance against a $1000 order blends to 9.9999 bips: + // cheaper than the 10-bip default in arithmetic, identical to it once the + // venue quantizes the builder fee to integer tenths of a basis point (a + // 0-bip discount either way). Claiming + // `source: 'subscription'` here would label a full-price order as waived + // and disagree with the cloid marker, which already gates on the + // quantized fee. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 0.01 })), + ); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('default'); + expect(resolution.discountBips).toBeUndefined(); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + }); + + it('still claims the subscription source when the blend survives quantization', async () => { + // $500 of the same $1000 order halves the fee to 5 bips, which is a real + // reduction on the wire, so subscription legitimately wins here. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 500 })), + ); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBe(5); + expect(resolution.subscriptionWaiverKind).toBe('partial'); + }); + it('does not start a benefits network read on the fee resolution path', async () => { const getPerpsBenefits = wireSubscription( jest.fn().mockResolvedValue(createBenefits()), diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index 28b88f5821f..5f5b9cb87d1 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -161,6 +161,16 @@ describe('markSubscriptionCloid', () => { ); }); + it('rejects a length-correct, prefix-matching id whose body is not hex', () => { + const malformed = `0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}${'z'.repeat(24)}`; + expect(malformed).toHaveLength(34); + + // Prefix and length alone are not a cloid: the venue never emits one with + // non-hex bytes, and treating it as ours would feed the decoder garbage. + expect(isSubscriptionProgramCloid(malformed)).toBe(false); + expect(hasFeeReductionAppliedFlag(malformed)).toBe(false); + }); + it('pads short entropy rather than producing a malformed cloid', () => { const cloid = markSubscriptionCloid({ entropy: 'abc' }); @@ -502,10 +512,34 @@ describe('applyFeeResolution', () => { subscriptionWaiverKind: 'full', }, amount: '1000', + chargesNoBuilderFee: true, }), ).toStrictEqual(twapFees); }); + it('re-prices a zero provider rate left behind by a concurrent waived submit', () => { + // A fully waived submit pushes its discount into provider state, so an + // ordinary preview racing it reads `metamaskFeeRate: 0` from a placement + // that does charge a builder fee. Without the policy flag this preview + // would inherit the other order's waiver and quote nothing. + const contaminated = { ...fees, metamaskFeeRate: 0, metamaskFeeAmount: 0 }; + + const priced = applyFeeResolution({ + fees: contaminated, + resolution: { + feeBips: 10, + discountBips: 0, + source: 'default', + subscription: createStatus({ eligible: false }), + }, + amount: '1000', + chargesNoBuilderFee: false, + }); + + expect(priced.metamaskFeeRate).toBe(0.001); + expect(priced.metamaskFeeAmount).toBe(1); + }); + it('re-prices rates without amounts when no notional was supplied', () => { const priced = applyFeeResolution({ fees, From 4628916184e4ea5edb1c89a75b1f2dc1f46327f9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 15:05:51 +0800 Subject: [PATCH 14/21] fix: address self-review feedback (TAT-3967) Make repricing a zero MetaMask fee rate opt-in. The previous commit added chargesMetamaskBuilderFee so a structural zero could be told apart from a waived one, but the call site mapped both false and undefined onto "charges a fee". A PerpsProvider written before the field existed reports a zero and no policy, so its quote gained the default 10-bip fee on an order that pays none. The policy is now carried as a tri-state, and only a provider that explicitly reports it does charge a builder fee has its zero overwritten. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/services/MarketDataService.ts | 2 +- .../src/utils/subscriptionFeeWaiver.ts | 19 +++++++++----- .../src/utils/subscriptionFeeWaiver.test.ts | 25 +++++++++++++++++-- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c6057729099..b25b349c25f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -38,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. -- Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. +- Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. Repricing a `0` rate is opt-in: a provider that does not report `chargesMetamaskBuilderFee` keeps its own rate, so a `PerpsProvider` implementation written before that field existed cannot gain a MetaMask fee it does not charge. - Reject client order IDs that match the subscription program marker and length but contain non-hex characters from `isSubscriptionProgramCloid`, which no longer treats such a value as a marked ID. - Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. - Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 381c2137892..55a71018ab4 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1348,7 +1348,7 @@ export class MarketDataService { fees, resolution: context.feeResolution, amount: params.amount, - chargesNoBuilderFee: fees.chargesMetamaskBuilderFee === false, + chargesBuilderFee: fees.chargesMetamaskBuilderFee, }); // Read-only preview of the same cached benefits snapshot the fee resolver diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 8f2b3572194..549db155af3 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -370,18 +370,19 @@ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { * @param params.fees - The provider's fee quote. * @param params.resolution - The unified fee resolution, when one was computed. * @param params.amount - Order notional (USD) as a string, when provided. - * @param params.chargesNoBuilderFee - True when this placement carries no - * MetaMask builder fee at all (a TWAP, for instance). Distinguishes a genuine - * zero from the zero a concurrent fully-waived submit leaves in provider state. + * @param params.chargesBuilderFee - The provider's policy on whether this + * placement carries a MetaMask builder fee at all, or undefined when it does not + * report one. Distinguishes a genuine zero (a TWAP, for instance) from the zero + * a concurrent fully-waived submit leaves in provider state. * @returns The quote with its MetaMask component and totals re-priced. */ export function applyFeeResolution(params: { fees: FeeCalculationResult; resolution: PerpsFeeResolution | undefined; amount?: string; - chargesNoBuilderFee?: boolean; + chargesBuilderFee?: boolean; }): FeeCalculationResult { - const { fees, resolution, amount, chargesNoBuilderFee = false } = params; + const { fees, resolution, amount, chargesBuilderFee } = params; if (resolution === undefined || fees.metamaskFeeRate === undefined) { return fees; @@ -391,7 +392,13 @@ export function applyFeeResolution(params: { // fee: it is also what a concurrent fully-waived submit leaves behind in // provider state. Distinguish the two by asking the policy, not the leftover // number — otherwise an ordinary preview inherits someone else's waiver. - if (fees.metamaskFeeRate === 0 && chargesNoBuilderFee) { + // + // Repricing a zero is opt-in: only a provider that explicitly reports it does + // charge a builder fee gets its zero overwritten. A provider that reports no + // policy at all keeps its zero, because a structural zero is the older and far + // likelier meaning, and quoting a fee the order will not pay is the worse of + // the two failures. + if (fees.metamaskFeeRate === 0 && chargesBuilderFee !== true) { return fees; } diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index 5f5b9cb87d1..7aaf3df0a64 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -512,11 +512,32 @@ describe('applyFeeResolution', () => { subscriptionWaiverKind: 'full', }, amount: '1000', - chargesNoBuilderFee: true, + chargesBuilderFee: false, }), ).toStrictEqual(twapFees); }); + it('leaves a zero rate untouched when the provider reports no fee policy', () => { + // A provider written before `chargesMetamaskBuilderFee` existed reports a + // structural zero and no policy. Repricing that to the default fee would + // quote a MetaMask fee the order never pays, so an unknown policy keeps the + // provider's own number. + const legacyFees = { ...fees, metamaskFeeRate: 0, metamaskFeeAmount: 0 }; + + expect( + applyFeeResolution({ + fees: legacyFees, + resolution: { + feeBips: 10, + discountBips: 0, + source: 'default', + subscription: createStatus({ eligible: false }), + }, + amount: '1000', + }), + ).toStrictEqual(legacyFees); + }); + it('re-prices a zero provider rate left behind by a concurrent waived submit', () => { // A fully waived submit pushes its discount into provider state, so an // ordinary preview racing it reads `metamaskFeeRate: 0` from a placement @@ -533,7 +554,7 @@ describe('applyFeeResolution', () => { subscription: createStatus({ eligible: false }), }, amount: '1000', - chargesNoBuilderFee: false, + chargesBuilderFee: true, }); expect(priced.metamaskFeeRate).toBe(0.001); From 4edd59fba45fc8abdd92b9243e843c6da9728d2b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 17:26:47 +0800 Subject: [PATCH 15/21] fix: address self-review feedback (MetaMask/core#10294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repair three JSDoc blocks garbled by a patch applied twice over itself: - applyFeeResolution: drop the duplicated summary line whose stray '/**' rendered as the literal 'resolution./**' in TypeDoc output. - #resolvePositionUnitPrice: same shape, drop the duplicated line. - #calculateFeeDiscountWithMeasurement: remove the stranded block left documenting #resolveBatchCloseNotionalUsd and reattach a corrected one, documenting orderNotionalUsd — the parameter whose absence resolves every bounded allowance as a full waiver. Comment-only; no executable statement changed. Co-Authored-By: Claude Opus 5 --- .../src/services/TradingService.ts | 22 ++++++++++++------- .../src/utils/subscriptionFeeWaiver.ts | 1 - 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 889b27af342..940edfb6227 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1177,13 +1177,6 @@ export class TradingService { }); } - /** - * Calculate fee discount with performance measurement - * Uses controller dependencies injected via setControllerDependencies() - * Helper method for placeOrder orchestration - * - * @returns The result of the operation. - */ /** * Resolve the total USD notional a batch close will submit. * @@ -1280,7 +1273,6 @@ export class TradingService { } /** - * The position's USD value per unit of size. /** * The position's USD value per unit of size. * * Used to price a partial close, which names a size but usually no price. @@ -1376,6 +1368,20 @@ export class TradingService { return price === undefined ? undefined : size * price; } + /** + * Resolve the fee discount for a submission, measuring the call. + * + * Uses controller dependencies injected via `setControllerDependencies()`; + * without them there is no resolver to ask and the caller pays the + * undiscounted fee. Helper method for the placement orchestration paths. + * + * @param orderNotionalUsd - The order's notional in USD, when it can be + * priced. This is what lets the subscription source resolve to a blended + * rate: omitting it resolves every bounded allowance as a full waiver, + * charging 0 bips on an order the preview quoted a blend for. + * @returns The resolved fee, or undefined when controller dependencies are + * unavailable. + */ async #calculateFeeDiscountWithMeasurement( orderNotionalUsd?: number, ): Promise { diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 549db155af3..485b7b494c3 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -354,7 +354,6 @@ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { } /** - * Re-price a fee quote from the unified fee resolution./** * Re-price a fee quote from the unified fee resolution. * * The provider quotes the MetaMask component from whatever discount the last From 5fc1ecfe55ac941f9a41f2d89f0da3be4bf058ab Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 17:42:08 +0800 Subject: [PATCH 16/21] fix: address CI feedback - tsconfig.lint.json: add the missing `../subscription-controller/tsconfig.lint.json` reference, via `yarn lint:tsconfigs:fix` (lint:tsconfigs:all). - README.md: add the `perps_controller --> subscription_controller` edge to the dependency graph, via `yarn readme-content:update` (readme-content:check). - subscriptionFeeWaiver.ts: apply oxfmt to `isSubscriptionProgramCloid` (lint:misc:check). - CHANGELOG.md: record that the trading-address CAIP-10 is built from the wallet's selected network rather than HyperLiquid's chain, so a client on another network registers under the wrong chain and its fills cannot be attributed. Co-Authored-By: Claude Opus 5 --- README.md | 1 + packages/perps-controller/CHANGELOG.md | 1 + packages/perps-controller/src/utils/subscriptionFeeWaiver.ts | 4 ++-- packages/perps-controller/tsconfig.lint.json | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6d49818c80b..44d4c82febb 100644 --- a/README.md +++ b/README.md @@ -595,6 +595,7 @@ linkStyle default opacity:0.5 perps_controller --> network_controller; perps_controller --> profile_sync_controller; perps_controller --> remote_feature_flag_controller; + perps_controller --> subscription_controller; perps_controller --> transaction_controller; phishing_controller --> address_book_controller; phishing_controller --> base_controller; diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 13f94222f6d..0bd4d4c37b8 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. Scale fills and fills on caller-supplied client order IDs therefore receive the discount without a decodable marker; attributing them needs a correlation other than the client order ID. - The subscription program marker is the registered id `0x0100`, zero-extended into the 4-byte marker field. - Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. + - The CAIP-10 identifier is built from the wallet's currently selected network, not from HyperLiquid's chain. A client whose selected network is not HyperLiquid therefore registers the trading address under the wrong chain, and a fill decoded off the HyperLiquid fan-out will not match it. Attribution is affected; fee resolution and order placement are not. ### Deprecated diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index 485b7b494c3..a48b4fcd2bb 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -243,8 +243,8 @@ export function isSubscriptionProgramCloid( // predicate is what gates the decoder. return Boolean( normalized && - CLOID_PATTERN.test(normalized) && - normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`), + CLOID_PATTERN.test(normalized) && + normalized.startsWith(`0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}`), ); } diff --git a/packages/perps-controller/tsconfig.lint.json b/packages/perps-controller/tsconfig.lint.json index 03280150d66..e33ba07398e 100644 --- a/packages/perps-controller/tsconfig.lint.json +++ b/packages/perps-controller/tsconfig.lint.json @@ -40,6 +40,9 @@ }, { "path": "../utils/tsconfig.lint.json" + }, + { + "path": "../subscription-controller/tsconfig.lint.json" } ] } From 221f5fc7160261e12a333c8d7a1299b3f27fc726 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 18 Sep 2026 17:44:58 +0800 Subject: [PATCH 17/21] fix: link changelog entries to the PR `Check changelog` requires each Unreleased entry to link the pull request that introduced it, which the released sections already do. All 35 top-level entries now carry the #10294 link; nested detail bullets are left unlinked, matching the surrounding convention. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 70 +++++++++++++------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0bd4d4c37b8..ee4de355539 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,57 +9,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. -- Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. -- Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID, and add an exact `./utils` subpath export so the barrel is importable as `@metamask/perps-controller/utils`. -- Add an optional `chargesMetamaskBuilderFee` field to `FeeCalculationResult`, which reports whether a placement can carry a MetaMask builder fee at all. A `metamaskFeeRate` of `0` is otherwise ambiguous between a venue or order type that has no builder field and a fully waived fee. -- Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. `SubscriptionController` exposes no address-registration action, so this runs through the injected dependency until one exists. +- Add optional `subscriptionWaiverKind` (`'full' | 'partial'`) and `subscriptionCoveredNotionalUsd` fields to `PerpsFeeResolution`, reporting how much of an order the subscription allowance covered. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Add the `perpsSubscriptionFeeWaiverEnabled` remote feature flag, which disables the subscription fee source on its own without affecting rewards or the default builder fee. An absent or malformed flag reads as enabled. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Export the subscription fee-waiver helpers from the `utils` barrel, including `hasFeeReductionAppliedFlag` and `isSubscriptionProgramCloid` for decoding a marked client order ID, and add an exact `./utils` subpath export so the barrel is importable as `@metamask/perps-controller/utils`. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Add an optional `chargesMetamaskBuilderFee` field to `FeeCalculationResult`, which reports whether a placement can carry a MetaMask builder fee at all. A `metamaskFeeRate` of `0` is otherwise ambiguous between a venue or order type that has no builder field and a fully waived fee. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. `SubscriptionController` exposes no address-registration action, so this runs through the injected dependency until one exists. ([#10294](https://github.com/MetaMask/core/pull/10294)) ### Changed -- **BREAKING:** `PerpsControllerAllowedActions` now includes `SubscriptionController:getBenefits`, so benefits hydration can run over the action `@metamask/subscription-controller` already exposes. Its micro-USD allowances are converted to USD at the boundary. +- **BREAKING:** `PerpsControllerAllowedActions` now includes `SubscriptionController:getBenefits`, so benefits hydration can run over the action `@metamask/subscription-controller` already exposes. Its micro-USD allowances are converted to USD at the boundary. ([#10294](https://github.com/MetaMask/core/pull/10294)) - This is a type break for every client, including clients that do not register the action. `Messenger` requires each child action to exist in the parent action union, so a strict parent messenger type must add `SubscriptionControllerGetBenefitsAction` before it will build. Runtime behavior is unchanged for those clients — an unregistered action falls back to the injected `subscription` dependency — but the build does not pass without the type. Coordinate the Mobile and Extension messenger updates with this release. -- **BREAKING:** `PerpsController.calculateFees` now quotes the subscription fee waiver as a blended rate derived from the order notional, so `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from previous releases when a subscription waiver applies. +- **BREAKING:** `PerpsController.calculateFees` now quotes the subscription fee waiver as a blended rate derived from the order notional, so `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from previous releases when a subscription waiver applies. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Pass the order notional (USD) as `FeeCalculationParams.amount`. It is now required for quote/submit parity, and omitting it changes the quote rather than preserving the previous one: a waiver whose remaining allowance is bounded is withheld entirely from a quote with no notional, so the preview reports the next-lowest source while a submit that can derive a notional still applies the waiver. A waiver with no reported allowance bound is unaffected. - Quoted rates are also repriced when rewards win, not only under a subscription waiver, and are quantized to the venue's tenths of a basis point so a quote equals the charged rate. -- Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. -- Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins and actually reduced the fee. Any other fee source leaves the client order ID untouched, as does a subscription waiver whose remaining allowance is too small to change the charged fee. +- Resolve the subscription fee waiver as `0` bips when the remaining allowance covers the order notional and `MaxFee × (1 − remaining / orderNotional)` otherwise, and let that rate compete in the lowest-fee comparison — a partial waiver can now lose to a VIP or season discount. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Mark the order's client order ID with the subscription program marker and a `fee_reduction_applied` flag on every placement, replace, TP/SL, batch-close, modify, and chase path when the subscription source wins and actually reduced the fee. Any other fee source leaves the client order ID untouched, as does a subscription waiver whose remaining allowance is too small to change the charged fee. ([#10294](https://github.com/MetaMask/core/pull/10294)) - A client order ID supplied by the caller through `OrderParams.clientOrderId` is never rewritten, so such orders are submitted exactly as requested and are not attributed to the subscription program. Only client order IDs this package generates carry the marking, and that provenance is tracked explicitly rather than inferred from the ID's leading bytes — a caller-supplied ID beginning with a reserved marker is still preserved. - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. Scale fills and fills on caller-supplied client order IDs therefore receive the discount without a decodable marker; attributing them needs a correlation other than the client order ID. - The subscription program marker is the registered id `0x0100`, zero-extended into the 4-byte marker field. -- Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. +- Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. ([#10294](https://github.com/MetaMask/core/pull/10294)) - The CAIP-10 identifier is built from the wallet's currently selected network, not from HyperLiquid's chain. A client whose selected network is not HyperLiquid therefore registers the trading address under the wrong chain, and a fill decoded off the HyperLiquid fan-out will not match it. Attribution is affected; fee resolution and order placement are not. ### Deprecated -- Deprecate `PerpsController.approveSubscriptionBuilderFee`, `PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated subscription builder address configuration. Subscription attribution now rides on the order's client order ID rather than a separate approved builder, so the controller method is a no-op that always resolves `true` — nothing needs approving, and a `false` would read as a setup failure to a caller that branches on it. The provider-side approval machinery is retained but unreachable from order construction. +- Deprecate `PerpsController.approveSubscriptionBuilderFee`, `PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated subscription builder address configuration. Subscription attribution now rides on the order's client order ID rather than a separate approved builder, so the controller method is a no-op that always resolves `true` — nothing needs approving, and a `false` would read as a setup failure to a caller that branches on it. The provider-side approval machinery is retained but unreachable from order construction. ([#10294](https://github.com/MetaMask/core/pull/10294)) ### Fixed -- Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. -- Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. Repricing a `0` rate is opt-in: a provider that does not report `chargesMetamaskBuilderFee` keeps its own rate, so a `PerpsProvider` implementation written before that field existed cannot gain a MetaMask fee it does not charge. -- Reject client order IDs that match the subscription program marker and length but contain non-hex characters from `isSubscriptionProgramCloid`, which no longer treats such a value as a marked ID. -- Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. -- Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. -- Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. -- Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. -- Preserve a cached benefits snapshot when a registered `SubscriptionController:getBenefits` handler rejects or throws synchronously and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. -- Drop a cached subscription waiver when `SubscriptionController:getBenefits` reports the profile is not subscribed. That rejection is a definitive answer rather than a failed read — the controller clears its own benefits state on the same path — so preserving the cached snapshot kept granting the waiver for the rest of the staleness window after entitlement ended. -- Stop marking the client order ID of an order edit. HyperLiquid's `modify` action carries no builder field, so no MetaMask fee is charged on it, and marking reported a fee reduction on an order that paid nothing. The replacement inherits the resting order's own attribution. -- Price a trigger placement (stop or take-profit) from its trigger price. Such an order carries no limit price, so its notional could not be derived and a bounded waiver was withheld from an order the provider prices later. -- Read the subscription metadata attached to a fee preview from the same resolution as the quoted rates. They were two separate reads, so a cache invalidation or feature-flag change between them could return metadata describing a waiver the rates did not reflect. -- Require positive evidence of a perps benefit before granting the waiver. `products.perps` is always present on the benefits response, so its existence proved nothing: a profile eligible for other products but carrying no perps builder fee, allowance or cap was granted an unbounded full waiver. -- Reject a malformed client order ID in `hasFeeReductionAppliedFlag` and `readSubscriptionCloidFlags`. Only length and prefix were validated, so a flag byte such as `1z` parsed as `1` and reported the order as fee-reduced. -- Leave quoted fee amounts untouched when the supplied order notional is zero or negative. Such a value is not an order size and previously produced negative `feeAmount` and `metamaskFeeAmount` figures. -- Distinguish an unregistered benefits action from a handler that throws on its first call. The latter was treated as the former and cached as a successful "no subscription" result. -- Reprice a fee preview to the undiscounted builder fee when the default source wins, rather than returning the provider's own rate. The provider's rate reflects the discount the last submit pushed into it, so a concurrent order could leak its discount into an unrelated quote. -- Price a take-profit/stop-loss update from the position read back through the routed provider when the caller supplies neither a position snapshot nor tracking data. Both are optional, and a bounded waiver is withheld without a notional, so a valid update silently lost the waiver. -- Price a close from the position the write can actually reach. A close read positions across every active provider and matched on symbol alone, so in aggregated mode a batch close summed positions it could not close, and a routed single close could price another provider's position for the same symbol. -- Quantize the previewed MetaMask builder fee to the venue's tenths of a basis point, matching what submit charges. A blended rate of 6.667 bips was previously quoted as 6.667 and charged as 6.6. -- Hydrate subscription benefits for a client that registers `SubscriptionController:getBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. -- Price a position close from the loaded position when the close parameters do not carry a notional. A full close commonly passes only a symbol, which previously resolved as an unbounded waiver rather than blending against the position's value; a partial close is now priced from the position's value per unit. -- Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. -- Register the newly selected trading address immediately on an account switch. Clearing the session's registrations alone only re-registered on the next fee preview, so an order submitted straight after a switch went unattributed. +- Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. Repricing a `0` rate is opt-in: a provider that does not report `chargesMetamaskBuilderFee` keeps its own rate, so a `PerpsProvider` implementation written before that field existed cannot gain a MetaMask fee it does not charge. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Reject client order IDs that match the subscription program marker and length but contain non-hex characters from `isSubscriptionProgramCloid`, which no longer treats such a value as a marked ID. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Preserve a cached benefits snapshot when a registered `SubscriptionController:getBenefits` handler rejects or throws synchronously and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Drop a cached subscription waiver when `SubscriptionController:getBenefits` reports the profile is not subscribed. That rejection is a definitive answer rather than a failed read — the controller clears its own benefits state on the same path — so preserving the cached snapshot kept granting the waiver for the rest of the staleness window after entitlement ended. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Stop marking the client order ID of an order edit. HyperLiquid's `modify` action carries no builder field, so no MetaMask fee is charged on it, and marking reported a fee reduction on an order that paid nothing. The replacement inherits the resting order's own attribution. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a trigger placement (stop or take-profit) from its trigger price. Such an order carries no limit price, so its notional could not be derived and a bounded waiver was withheld from an order the provider prices later. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Read the subscription metadata attached to a fee preview from the same resolution as the quoted rates. They were two separate reads, so a cache invalidation or feature-flag change between them could return metadata describing a waiver the rates did not reflect. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Require positive evidence of a perps benefit before granting the waiver. `products.perps` is always present on the benefits response, so its existence proved nothing: a profile eligible for other products but carrying no perps builder fee, allowance or cap was granted an unbounded full waiver. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Reject a malformed client order ID in `hasFeeReductionAppliedFlag` and `readSubscriptionCloidFlags`. Only length and prefix were validated, so a flag byte such as `1z` parsed as `1` and reported the order as fee-reduced. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Leave quoted fee amounts untouched when the supplied order notional is zero or negative. Such a value is not an order size and previously produced negative `feeAmount` and `metamaskFeeAmount` figures. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Distinguish an unregistered benefits action from a handler that throws on its first call. The latter was treated as the former and cached as a successful "no subscription" result. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Reprice a fee preview to the undiscounted builder fee when the default source wins, rather than returning the provider's own rate. The provider's rate reflects the discount the last submit pushed into it, so a concurrent order could leak its discount into an unrelated quote. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a take-profit/stop-loss update from the position read back through the routed provider when the caller supplies neither a position snapshot nor tracking data. Both are optional, and a bounded waiver is withheld without a notional, so a valid update silently lost the waiver. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a close from the position the write can actually reach. A close read positions across every active provider and matched on symbol alone, so in aggregated mode a batch close summed positions it could not close, and a routed single close could price another provider's position for the same symbol. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Quantize the previewed MetaMask builder fee to the venue's tenths of a basis point, matching what submit charges. A blended rate of 6.667 bips was previously quoted as 6.667 and charged as 6.6. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Hydrate subscription benefits for a client that registers `SubscriptionController:getBenefits` without also injecting the optional `subscription` dependency. Both the eligibility read and the benefits refresh previously required the injected dependency, so a client adopting only the controller action always resolved as having no subscription source and never received a waiver. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a position close from the loaded position when the close parameters do not carry a notional. A full close commonly passes only a symbol, which previously resolved as an unbounded waiver rather than blending against the position's value; a partial close is now priced from the position's value per unit. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Resolve the subscription fee waiver against the order notional on the submit path, not just in previews. Order placement, order edits, position closes, batch closes, take-profit/stop-loss updates, and position flips previously resolved the waiver with no notional, so a bounded allowance always resolved as a full waiver — an order was quoted a blended rate and then charged nothing, over-consuming the allowance and marking its client order ID as fully waived. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Register the newly selected trading address immediately on an account switch. Clearing the session's registrations alone only re-registered on the next fee preview, so an order submitted straight after a switch went unattributed. ([#10294](https://github.com/MetaMask/core/pull/10294)) ## [17.2.0] From 57c0962c7c34a5226c8e506d9caf27f92d1c316d Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 21 Sep 2026 22:42:44 +0800 Subject: [PATCH 18/21] fix(perps): address PR feedback Register the trading address under HyperLiquid's own chain (eip155:999 / eip155:998) instead of the wallet's selected network, so a fill decoded off the HyperLiquid fan-out can match the registered identifier. PerpsController supplies isTestnet at both call sites. Price a partial TP/SL update from the trigger size it submits rather than the whole position, taking the larger of takeProfitSize and stopLossSize since both go up under one builder context. An unpriceable partial still falls back to the position notional. Honor closeAll ahead of symbols when pricing a batch close, matching the provider's own selection precedence. Discard a trading-address registration whose profile was invalidated while it was in flight, reusing the benefits-cache epoch fence. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 5 +- .../perps-controller/src/PerpsController.ts | 8 +- .../src/services/RewardsIntegrationService.ts | 41 +++-- .../src/services/TradingService.ts | 59 ++++++- .../src/PerpsController.operations.test.ts | 6 +- .../RewardsIntegrationService.test.ts | 70 ++++++++- .../tests/src/services/TradingService.test.ts | 148 ++++++++++++++++++ 7 files changed, 316 insertions(+), 21 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ee4de355539..531ce26a4a4 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scale-ladder client order IDs keep their own group marker and rung index, so group recovery and cancel-by-client-order-ID are unaffected. A ladder's reserved flag byte is set when the waiver applies, but `hasFeeReductionAppliedFlag` does not report it — see the Fixed entry below. Scale fills and fills on caller-supplied client order IDs therefore receive the discount without a decodable marker; attributing them needs a correlation other than the client order ID. - The subscription program marker is the registered id `0x0100`, zero-extended into the 4-byte marker field. - Register the current HyperLiquid trading address with the subscription profile during `calculateFees`, and re-register it after the selected account changes. ([#10294](https://github.com/MetaMask/core/pull/10294)) - - The CAIP-10 identifier is built from the wallet's currently selected network, not from HyperLiquid's chain. A client whose selected network is not HyperLiquid therefore registers the trading address under the wrong chain, and a fill decoded off the HyperLiquid fan-out will not match it. Attribution is affected; fee resolution and order placement are not. + - The CAIP-10 identifier names HyperLiquid's own chain (`eip155:999`, or `eip155:998` on testnet), not the wallet's currently selected network, so it matches the chain a fill decoded off the HyperLiquid fan-out reports. ### Deprecated @@ -43,6 +43,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mark the client order ID of every chase replacement when the chase was placed under a subscription waiver. The marking read the live fee resolution, which the trading service clears as soon as the initial placement returns, so a replacement paid the discounted fee the session captured while shipping an unmarked ID. The decision is now captured with the session's builder fee, for the same reason. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Withhold the subscription waiver when the allowance is bounded and the order notional cannot be determined. Such an order previously resolved as a full waiver, charging nothing on an order of unknown size and over-consuming the allowance. An unbounded allowance is unaffected. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Price a batch close from the positions of the provider that submits it. The notional previously summed every aggregated provider's positions, while the batch routes to one, which could inflate the notional and shrink the waiver. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Honor `closeAll` ahead of `symbols` when pricing a batch close, matching the provider's own selection precedence. A `closeAll` request that also carried a symbol list was priced from the filtered subset while the batch closed every position, so the waiver was granted against a smaller notional than the order submitted. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a partial TP/SL update from the trigger size it submits rather than the whole position. `takeProfitSize` and `stopLossSize` are honored by the provider, so a partial trigger on a large position was priced against the full position notional and blended a fee on an order the remaining allowance covered outright. The larger of the two sizes prices the action, since both triggers are submitted under one builder context. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Discard a trading-address registration whose profile was invalidated while it was in flight. The completion previously cached the address regardless, so a new profile reusing that address skipped its own registration and left its fills unattributable. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Preserve a cached benefits snapshot when a registered `SubscriptionController:getBenefits` handler rejects or throws synchronously and no injected `subscription` dependency exists to fall back to. The rejection was previously swallowed and stored as a successful "no subscription" result, erasing a waiver the user was still entitled to. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Drop a cached subscription waiver when `SubscriptionController:getBenefits` reports the profile is not subscribed. That rejection is a definitive answer rather than a failed read — the controller clears its own benefits state on the same path — so preserving the cached snapshot kept granting the waiver for the rest of the staleness window after entitlement ended. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Stop marking the client order ID of an order edit. HyperLiquid's `modify` action carries no builder field, so no MetaMask fee is charged on it, and marking reported a fee reduction on an order that paid nothing. The replacement inherits the resting order's own attribution. ([#10294](https://github.com/MetaMask/core/pull/10294)) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index d5a35a5c173..10044831789 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1293,7 +1293,9 @@ export class PerpsController extends BaseController< ); if (switchedAccount) { this.#rewardsIntegrationService - .registerTradingAddress(switchedAccount.address) + .registerTradingAddress(switchedAccount.address, { + isTestnet: this.state.isTestnet, + }) .catch(() => { /* never blocks an account switch */ }); @@ -5817,7 +5819,9 @@ export class PerpsController extends BaseController< const selectedAccount = getSelectedEvmAccountFromMessenger(this.messenger); if (selectedAccount) { this.#rewardsIntegrationService - .registerTradingAddress(selectedAccount.address) + .registerTradingAddress(selectedAccount.address, { + isTestnet: this.state.isTestnet, + }) .catch(() => { /* never blocks a fee preview */ }); diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 51edd72fef3..c86ee2534b0 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -3,6 +3,8 @@ import type { SubscriptionBenefitsResponse } from '@metamask/subscription-contro import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, + HYPERLIQUID_MAINNET_CHAIN_ID, + HYPERLIQUID_TESTNET_CHAIN_ID, } from '../constants/hyperLiquidConfig.js'; import { PERPS_CONSTANTS, @@ -598,9 +600,16 @@ export class RewardsIntegrationService { * — is what closes it. * * @param address - The EVM trading address to register. + * @param options - Registration options. + * @param options.isTestnet - Whether the caller is trading against the + * HyperLiquid testnet. Selects which HyperLiquid chain the CAIP-10 names; + * defaults to mainnet. * @returns A promise that resolves once the attempt settles. */ - async registerTradingAddress(address: string): Promise { + async registerTradingAddress( + address: string, + options?: { isTestnet?: boolean }, + ): Promise { const source = this.#deps.subscription; if (!source?.registerTradingAddress) { // Visible rather than silent: a client wired only to the messenger has no @@ -614,14 +623,14 @@ export class RewardsIntegrationService { } try { - const networkState = this.#messenger.call('NetworkController:getState'); - const chainId = this.#getChainIdForNetwork( - networkState.selectedNetworkClientId, - ); - - if (!chainId) { - return; - } + // HyperLiquid's own chain, not the wallet's selected network. The address + // is being announced so a fill decoded off the HyperLiquid fan-out can be + // matched back to a profile, and that fill names `eip155:999` / + // `eip155:998`. Registering under whatever network the wallet happened to + // have selected produces an identifier no fill will ever match. + const chainId = options?.isTestnet + ? HYPERLIQUID_TESTNET_CHAIN_ID + : HYPERLIQUID_MAINNET_CHAIN_ID; const caipAccountId = formatAccountToCaipAccountId( address, @@ -639,7 +648,21 @@ export class RewardsIntegrationService { return; } + // Fence the completion against an invalidation that lands while this + // registration is in flight. `invalidateSubscriptionBenefits` clears the + // registered set because the profile behind it changed; writing back + // afterwards would re-add an address registered for the *previous* + // profile, and the new profile would then skip its own registration and + // leave its fills unattributable. + const epoch = this.#benefitsEpoch; await source.registerTradingAddress(caipAccountId); + if (epoch !== this.#benefitsEpoch) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Trading address registration superseded', + { caipAccountId }, + ); + return; + } this.#registeredTradingAddresses.add(caipAccountId); this.#deps.debugLogger.log( diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 940edfb6227..2b5595fec22 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1209,8 +1209,14 @@ export class TradingService { provider, }); // `closeAll`, or an omitted/empty symbol list, means every position. + // `closeAll` is checked first because the provider gives it precedence + // over a symbol list: pricing a filtered subset while the batch closes + // everything would resolve the fee against too small a notional and + // over-grant the waiver. const selected = - params.symbols && params.symbols.length > 0 + params.closeAll !== true && + params.symbols && + params.symbols.length > 0 ? positions.filter((position) => params.symbols?.includes(position.symbol), ) @@ -2368,12 +2374,23 @@ export class TradingService { }); // Get fee discount from rewards. A TP/SL update carries no notional of - // its own, so it is priced from the position the triggers protect: the - // caller's snapshot or tracking data when supplied, and otherwise the - // position read back through the routed provider. Both caller fields are - // optional, and a bounded waiver is withheld without a notional, so - // relying on them alone silently drops the waiver on a valid update. - const tpslNotionalUsd = + // its own, so it is priced from what the triggers actually cover. + // + // A partial update states its own quantity in `takeProfitSize` / + // `stopLossSize`, and the provider submits exactly that + // (`resolveTpslSize`). Pricing such an update from the whole position + // would resolve the fee against far more notional than is submitted and + // blend away a waiver that should have been full. The two triggers go up + // under one builder context, so the larger size prices the action; an + // omitted size covers the whole position and therefore prices as such. + // + // Only when neither size is given does this fall back to the position the + // triggers protect: the caller's snapshot or tracking data when supplied, + // and otherwise the position read back through the routed provider. Both + // caller fields are optional, and a bounded waiver is withheld without a + // notional, so relying on them alone silently drops the waiver on a valid + // update. + const positionNotionalUsd = async (): Promise => this.#resolveOrderNotionalUsd({ usdAmount: params.position?.positionValue, size: params.trackingData?.positionSize?.toString(), @@ -2389,6 +2406,34 @@ export class TradingService { }) )?.positionValue, }); + + const triggerSize = [params.takeProfitSize, params.stopLossSize] + .map((size) => (size === undefined ? NaN : Number.parseFloat(size))) + .filter((size) => Number.isFinite(size) && size > 0) + .reduce( + (largest, size) => + largest === undefined || size > largest ? size : largest, + undefined, + ); + + const partialNotionalUsd = + triggerSize === undefined + ? undefined + : this.#resolveOrderNotionalUsd({ + size: triggerSize.toString(), + price: params.takeProfitPrice ?? params.stopLossPrice, + currentPrice: + params.trackingData?.entryPrice ?? + (params.position?.entryPrice === undefined + ? undefined + : Number.parseFloat(params.position.entryPrice)), + }); + + // An unpriceable partial still falls back to the position rather than + // resolving to no notional: over-pricing withholds part of a waiver, + // while no notional withholds a bounded one entirely. + const tpslNotionalUsd = + partialNotionalUsd ?? (await positionNotionalUsd()); const feeResolution = await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd); diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index a0a94b30ce4..4852c3a225f 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1759,7 +1759,11 @@ describe('PerpsController', () => { symbol: 'BTC', }); - expect(register).toHaveBeenCalledWith(expect.stringMatching(/^0x/u)); + // The controller supplies the HyperLiquid network, so the registration + // names HyperLiquid's chain rather than the wallet's selected one. + expect(register).toHaveBeenCalledWith(expect.stringMatching(/^0x/u), { + isTestnet: controller.state.isTestnet, + }); jest.restoreAllMocks(); }); diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index b9a4bad94f7..7f123362d67 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1314,8 +1314,9 @@ describe('RewardsIntegrationService', () => { await service.registerTradingAddress(mockEvmAccount.address); expect(registerTradingAddress).toHaveBeenCalledTimes(1); + // HyperLiquid mainnet (`eip155:999`), not the wallet's selected network. expect(registerTradingAddress).toHaveBeenCalledWith( - expect.stringMatching(/^eip155:1:0x/u), + expect.stringMatching(/^eip155:999:0x/u), ); // Account switch: the new address has to announce itself. @@ -1325,6 +1326,73 @@ describe('RewardsIntegrationService', () => { expect(registerTradingAddress).toHaveBeenCalledTimes(2); }); + it('registers against the HyperLiquid chain rather than the selected network', async () => { + // The address is announced so a fill decoded off the HyperLiquid fan-out + // can be matched to a profile, and that fill names eip155:999 / + // eip155:998. Registering under the wallet's selected network would + // produce an identifier no fill can ever match. + const registerTradingAddress = jest.fn().mockResolvedValue(undefined); + setupMessengerDefaults(); + (mockDeps as { subscription?: unknown }).subscription = { + getPerpsBenefits: jest.fn().mockResolvedValue(null), + registerTradingAddress, + }; + + await service.registerTradingAddress(mockEvmAccount.address, { + isTestnet: true, + }); + + expect(registerTradingAddress).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:998:0x/u), + ); + + service.resetRegisteredTradingAddresses(); + await service.registerTradingAddress(mockEvmAccount.address, { + isTestnet: false, + }); + + expect(registerTradingAddress).toHaveBeenLastCalledWith( + expect.stringMatching(/^eip155:999:0x/u), + ); + }); + + it('does not cache a registration that an invalidation superseded', async () => { + // A registration issued for profile A must not mark itself complete after + // the profile changed: the new profile would then skip its own + // registration and leave its fills unattributable. + let releaseRegistration: () => void = () => undefined; + const registerTradingAddress = jest + .fn() + // Only the first call blocks; the re-registration below resolves at + // once so the assertion does not depend on releasing it too. + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + releaseRegistration = resolve; + }), + ) + .mockResolvedValue(undefined); + setupMessengerDefaults(); + (mockDeps as { subscription?: unknown }).subscription = { + getPerpsBenefits: jest.fn().mockResolvedValue(null), + registerTradingAddress, + }; + + const pending = service.registerTradingAddress(mockEvmAccount.address); + + // Profile switch lands while the registration is still in flight. + service.invalidateSubscriptionBenefits(); + releaseRegistration(); + await pending; + + expect(registerTradingAddress).toHaveBeenCalledTimes(1); + + // The new profile must register the same address for itself. + await service.registerTradingAddress(mockEvmAccount.address); + + expect(registerTradingAddress).toHaveBeenCalledTimes(2); + }); + it('reports the gap when the client has no registration hook', async () => { // A messenger-only client hydrates benefits but cannot register an // address, so its fills go unattributed. Nothing is raised — it is a diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index b062a5c14f7..13083615d8b 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2136,6 +2136,83 @@ describe('TradingService', () => { ); }); + it('prices a batch close from every position when closeAll overrides a symbol list', async () => { + // The provider gives `closeAll` precedence over `symbols`, so pricing the + // filtered subset would resolve the fee against $1,000 while the batch + // actually closes $10,000 — over-granting the waiver. + const batchProvider = { + ...mockProvider, + getWriteProviderId: jest.fn(() => 'hyperliquid'), + getPositions: jest.fn().mockResolvedValue([ + { + symbol: 'BTC', + size: '0.02', + positionValue: '1000', + providerId: 'hyperliquid', + }, + { + symbol: 'ETH', + size: '3', + positionValue: '9000', + providerId: 'hyperliquid', + }, + ]), + closePositions: jest.fn().mockResolvedValue({ + success: true, + successCount: 2, + failureCount: 0, + results: [], + }), + } as unknown as jest.Mocked; + + await tradingService.closePositions({ + provider: batchProvider, + params: { closeAll: true, symbols: ['BTC'] }, + context: mockContext, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 10000, + ); + }); + + it('prices a batch close from the named symbols when closeAll is not set', async () => { + const batchProvider = { + ...mockProvider, + getWriteProviderId: jest.fn(() => 'hyperliquid'), + getPositions: jest.fn().mockResolvedValue([ + { + symbol: 'BTC', + size: '0.02', + positionValue: '1000', + providerId: 'hyperliquid', + }, + { + symbol: 'ETH', + size: '3', + positionValue: '9000', + providerId: 'hyperliquid', + }, + ]), + closePositions: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }), + } as unknown as jest.Mocked; + + await tradingService.closePositions({ + provider: batchProvider, + params: { symbols: ['BTC'] }, + context: mockContext, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 1000, + ); + }); + const mockPositions: Position[] = [ { symbol: 'BTC', @@ -2377,6 +2454,77 @@ describe('TradingService', () => { stopLossCount: 0, }; + it('prices a partial TP/SL update from the submitted trigger size', async () => { + // The provider submits exactly `takeProfitSize` (`resolveTpslSize`), so + // pricing this from the whole $25,000 position would resolve the fee + // against ten times what is submitted and blend away a waiver that + // should have been full. + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitSize: '0.05', + position: mockPosition, + }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + // 0.05 BTC at the 55000 trigger, not the position's 25000. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 2750, + ); + }); + + it('prices a partial TP/SL update from the larger of the two trigger sizes', async () => { + // Both triggers go up under one builder context, so the action is priced + // by whichever covers more of the position. + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + takeProfitSize: '0.05', + stopLossSize: '0.1', + position: mockPosition, + }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + // 0.1 BTC at the 55000 take-profit trigger. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 5500, + ); + }); + + it('prices a whole-position TP/SL update from the position notional', async () => { + // No trigger size means the triggers cover the whole position, so the + // position notional is still the right price. + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '55000', + position: mockPosition, + }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 25000, + ); + }); + it('updates TP/SL successfully without fee discount', async () => { const params: UpdatePositionTPSLParams = { symbol: 'BTC', From e430c9f1d063e03031300bee1bb2ba97e999f67d Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 21 Sep 2026 23:08:19 +0800 Subject: [PATCH 19/21] fix: address CI feedback Restore Prettier formatting in TradingService, which failed lint:misc:check on the previous push. Price a Scale ladder from the midpoint of scaleMinPrice and scaleMaxPrice. A Scale placement states no single price, so its notional could not be derived and a bounded subscription waiver was withheld at submit after a preview that quoted one. A stated usdAmount still takes precedence. Chase and TWAP state no price at all and remain priced from usdAmount or the quoted market price. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 1 + .../src/services/TradingService.ts | 33 +++++++++++-- .../tests/src/services/TradingService.test.ts | 49 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 531ce26a4a4..ba2a72e21ce 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Drop a cached subscription waiver when `SubscriptionController:getBenefits` reports the profile is not subscribed. That rejection is a definitive answer rather than a failed read — the controller clears its own benefits state on the same path — so preserving the cached snapshot kept granting the waiver for the rest of the staleness window after entitlement ended. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Stop marking the client order ID of an order edit. HyperLiquid's `modify` action carries no builder field, so no MetaMask fee is charged on it, and marking reported a fee reduction on an order that paid nothing. The replacement inherits the resting order's own attribution. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Price a trigger placement (stop or take-profit) from its trigger price. Such an order carries no limit price, so its notional could not be derived and a bounded waiver was withheld from an order the provider prices later. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Price a Scale ladder from the midpoint of `scaleMinPrice` and `scaleMaxPrice`. A Scale placement states no single price, so its notional could not be derived and a bounded waiver was withheld at submit after a preview that quoted one. A stated `usdAmount` still takes precedence. Chase and TWAP placements state no price at all and remain priced from `usdAmount` or the quoted market price. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Read the subscription metadata attached to a fee preview from the same resolution as the quoted rates. They were two separate reads, so a cache invalidation or feature-flag change between them could return metadata describing a waiver the rates did not reflect. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Require positive evidence of a perps benefit before granting the waiver. `products.perps` is always present on the benefits response, so its existence proved nothing: a profile eligible for other products but carrying no perps builder fee, allowance or cap was granted an unbounded full waiver. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Reject a malformed client order ID in `hasFeeReductionAppliedFlag` and `readSubscriptionCloidFlags`. Only length and prefix were validated, so a flag byte such as `1z` parsed as `1` and reported the order as fee-reduced. ([#10294](https://github.com/MetaMask/core/pull/10294)) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 2b5595fec22..81058c5af68 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1214,9 +1214,7 @@ export class TradingService { // everything would resolve the fee against too small a notional and // over-grant the waiver. const selected = - params.closeAll !== true && - params.symbols && - params.symbols.length > 0 + params.closeAll !== true && params.symbols && params.symbols.length > 0 ? positions.filter((position) => params.symbols?.includes(position.symbol), ) @@ -1321,6 +1319,10 @@ export class TradingService { * limit price of its own. * @param params.currentPrice - Live market price the order was quoted against. * @param params.priceAtCalculation - Price snapshot taken when size was derived. + * @param params.scaleMinPrice - Lowest rung of a Scale ladder, when the + * placement is one. + * @param params.scaleMaxPrice - Highest rung of a Scale ladder, when the + * placement is one. * @returns The order notional in USD, or undefined when it cannot be priced. */ #resolveOrderNotionalUsd(params: { @@ -1330,6 +1332,8 @@ export class TradingService { triggerPrice?: string; currentPrice?: number; priceAtCalculation?: number; + scaleMinPrice?: string; + scaleMaxPrice?: string; }): number | undefined { const usdAmount = params.usdAmount === undefined @@ -1357,9 +1361,32 @@ export class TradingService { params.triggerPrice === undefined ? undefined : Number.parseFloat(params.triggerPrice); + // A Scale ladder states no single price: its rungs span `scaleMinPrice` to + // `scaleMaxPrice`, so the midpoint is what the whole ladder averages out + // at. Without this a bounded waiver is withheld from every Scale placement + // that carries no USD amount, after a preview that quoted one. + const scaleMinPrice = + params.scaleMinPrice === undefined + ? undefined + : Number.parseFloat(params.scaleMinPrice); + const scaleMaxPrice = + params.scaleMaxPrice === undefined + ? undefined + : Number.parseFloat(params.scaleMaxPrice); + const scaleMidPrice = + scaleMinPrice !== undefined && + scaleMaxPrice !== undefined && + Number.isFinite(scaleMinPrice) && + Number.isFinite(scaleMaxPrice) && + scaleMinPrice > 0 && + scaleMaxPrice > 0 + ? (scaleMinPrice + scaleMaxPrice) / 2 + : undefined; + const price = [ limitPrice, triggerPrice, + scaleMidPrice, params.priceAtCalculation, params.currentPrice, ] diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 13083615d8b..0b10ce3e4b6 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -272,6 +272,55 @@ describe('TradingService', () => { ); }); + it('prices a Scale ladder from the midpoint of its bounds', async () => { + // A Scale placement states no single price — its rungs span + // scaleMinPrice to scaleMaxPrice. Without pricing from those bounds a + // bounded waiver is withheld at submit after a preview quoted one. + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'scale', + scaleMinPrice: '40000', + scaleMaxPrice: '60000', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // 0.02 BTC at the 50000 midpoint of the ladder. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 1000, + ); + }); + + it('prefers a stated USD amount over the Scale ladder bounds', async () => { + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + usdAmount: '2500', + orderType: 'scale', + scaleMinPrice: '40000', + scaleMaxPrice: '60000', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 2500, + ); + }); + it('still resolves a fee when the order cannot be priced', async () => { mockProvider.placeOrder.mockResolvedValue({ success: true }); From 99f8a1c493307d4e659b33203d2f22a69d381980 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 22 Sep 2026 16:31:56 +0800 Subject: [PATCH 20/21] fix(perps): address PR review feedback --- .../src/services/RewardsIntegrationService.ts | 68 ++++++---- .../src/services/TradingService.ts | 121 +++++++++++++----- packages/perps-controller/src/types/index.ts | 5 +- .../perps-controller/src/types/messenger.ts | 11 ++ .../RewardsIntegrationService.test.ts | 15 +++ .../tests/src/services/TradingService.test.ts | 55 +++++++- 6 files changed, 212 insertions(+), 63 deletions(-) diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index c86ee2534b0..cc74211a2bb 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -586,18 +586,9 @@ export class RewardsIntegrationService { * never throws: it is observability plumbing, and a failure here must not * block a fee preview. * - * `SubscriptionController` exposes no address-registration action today, so - * this runs entirely through the injected `subscription` dependency when a - * client supplies one. Wiring it to a messenger action is left until that - * action exists rather than calling a name nothing answers. - * - * **A messenger-only client therefore registers nothing.** Benefits hydration - * works over `SubscriptionController:getBenefits`, but a client that adopts - * only the messenger and injects no `registerTradingAddress` hook gets no - * address registration at all, and its fills cannot be attributed to a - * profile. That is a wiring gap rather than a failure, so it is logged rather - * than raised; supplying the hook — or a registration action, once one exists - * — is what closes it. + * New clients may route this through the structural + * `SubscriptionController:registerAddress` action. Older clients continue to + * use the injected `subscription` hook as a compatibility fallback. * * @param address - The EVM trading address to register. * @param options - Registration options. @@ -610,18 +601,6 @@ export class RewardsIntegrationService { address: string, options?: { isTestnet?: boolean }, ): Promise { - const source = this.#deps.subscription; - if (!source?.registerTradingAddress) { - // Visible rather than silent: a client wired only to the messenger has no - // way to register, and a missing registration is otherwise indetectable - // until fills arrive unattributed. - this.#deps.debugLogger.log( - 'RewardsIntegrationService: No trading-address registration hook wired; fills will be unattributed', - { address }, - ); - return; - } - try { // HyperLiquid's own chain, not the wallet's selected network. The address // is being announced so a fill decoded off the HyperLiquid fan-out can be @@ -648,14 +627,51 @@ export class RewardsIntegrationService { return; } + // Capture the identity before invoking either integration. If profile + // invalidation happens while the handler is pending, its completion must + // not repopulate the registration cache for the old profile. + const epoch = this.#benefitsEpoch; + + // New clients can route registration through SubscriptionController. + // Older clients do not expose this action and continue using the + // injected hook below. + let registration: Promise | undefined; + try { + const result = this.#messenger.call( + 'SubscriptionController:registerAddress', + caipAccountId, + ); + if (result !== undefined) { + registration = Promise.resolve(result); + } + } catch (error) { + if (!isUnregisteredActionError(error) && !this.#deps.subscription) { + throw error; + } + } + + if (!registration) { + const source = this.#deps.subscription; + if (!source?.registerTradingAddress) { + // Visible rather than silent: a client wired only to benefits has no + // way to register, and a missing registration is otherwise + // indetectable until fills arrive unattributed. + this.#deps.debugLogger.log( + 'RewardsIntegrationService: No trading-address registration hook wired; fills will be unattributed', + { address }, + ); + return; + } + registration = source.registerTradingAddress(caipAccountId); + } + // Fence the completion against an invalidation that lands while this // registration is in flight. `invalidateSubscriptionBenefits` clears the // registered set because the profile behind it changed; writing back // afterwards would re-add an address registered for the *previous* // profile, and the new profile would then skip its own registration and // leave its fills unattributable. - const epoch = this.#benefitsEpoch; - await source.registerTradingAddress(caipAccountId); + await registration; if (epoch !== this.#benefitsEpoch) { this.#deps.debugLogger.log( 'RewardsIntegrationService: Trading address registration superseded', diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 81058c5af68..c4a1e7b3d2e 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -34,7 +34,10 @@ import type { PerpsFeeResolution, } from '../types/index.js'; import { ensureError } from '../utils/errorUtils.js'; -import { isLimitExecutionOrderType } from '../utils/orderTypes.js'; +import { + isLimitExecutionOrderType, + SCALE_ORDER_COUNT, +} from '../utils/orderTypes.js'; import type { RewardsIntegrationService } from './RewardsIntegrationService.js'; import type { ServiceContext } from './ServiceContext.js'; @@ -1323,6 +1326,10 @@ export class TradingService { * placement is one. * @param params.scaleMaxPrice - Highest rung of a Scale ladder, when the * placement is one. + * @param params.scaleNumOrders - Number of rungs in a Scale ladder, when + * the placement supplies it. + * @param params.scaleSkew - Optional linear size weighting across a Scale + * ladder. A value above 1 puts more size on the highest-price rungs. * @returns The order notional in USD, or undefined when it cannot be priced. */ #resolveOrderNotionalUsd(params: { @@ -1334,6 +1341,8 @@ export class TradingService { priceAtCalculation?: number; scaleMinPrice?: string; scaleMaxPrice?: string; + scaleNumOrders?: number; + scaleSkew?: number; }): number | undefined { const usdAmount = params.usdAmount === undefined @@ -1361,10 +1370,13 @@ export class TradingService { params.triggerPrice === undefined ? undefined : Number.parseFloat(params.triggerPrice); - // A Scale ladder states no single price: its rungs span `scaleMinPrice` to - // `scaleMaxPrice`, so the midpoint is what the whole ladder averages out - // at. Without this a bounded waiver is withheld from every Scale placement - // that carries no USD amount, after a preview that quoted one. + // A Scale ladder states no single price. For an even ladder the midpoint + // is its average price, but a skew weights the rung sizes, so the midpoint + // is no longer the ladder's notional price. Use the same linear weights as + // `splitScaleSizes` when the caller supplies the rung count. The provider + // rounds each rung onto its venue size grid later; this is the equivalent + // weighted calculation before that bounded rounding, without making the + // service depend on provider-specific market metadata. const scaleMinPrice = params.scaleMinPrice === undefined ? undefined @@ -1373,20 +1385,44 @@ export class TradingService { params.scaleMaxPrice === undefined ? undefined : Number.parseFloat(params.scaleMaxPrice); - const scaleMidPrice = + const scaleWeightedPrice = scaleMinPrice !== undefined && scaleMaxPrice !== undefined && Number.isFinite(scaleMinPrice) && Number.isFinite(scaleMaxPrice) && scaleMinPrice > 0 && scaleMaxPrice > 0 - ? (scaleMinPrice + scaleMaxPrice) / 2 + ? ((): number => { + const count = params.scaleNumOrders; + const skew = params.scaleSkew; + if ( + count === undefined || + !Number.isInteger(count) || + count < SCALE_ORDER_COUNT.min || + count > SCALE_ORDER_COUNT.max || + (skew !== undefined && (!Number.isFinite(skew) || skew <= 0)) + ) { + return (scaleMinPrice + scaleMaxPrice) / 2; + } + + let weightedPrice = 0; + let weightSum = 0; + for (let index = 0; index < count; index++) { + const weight = 1 + (((skew ?? 1) - 1) * index) / (count - 1); + const price = + scaleMinPrice + + ((scaleMaxPrice - scaleMinPrice) * index) / (count - 1); + weightedPrice += price * weight; + weightSum += weight; + } + return weightedPrice / weightSum; + })() : undefined; const price = [ limitPrice, triggerPrice, - scaleMidPrice, + scaleWeightedPrice, params.priceAtCalculation, params.currentPrice, ] @@ -2433,34 +2469,55 @@ export class TradingService { }) )?.positionValue, }); + let positionNotionalUsdPromise: Promise | undefined; + const getPositionNotionalUsd = (): Promise => + (positionNotionalUsdPromise ??= positionNotionalUsd()); + + // Each trigger has its own price and submitted size. An omitted size is + // resolved by the provider as the full position, so mixed partial/full + // updates must include the position notional when selecting the largest + // trigger for the shared builder context. + const triggerNotionalsUsd = await Promise.all( + [ + { + price: params.takeProfitPrice, + size: params.takeProfitSize, + }, + { + price: params.stopLossPrice, + size: params.stopLossSize, + }, + ] + .filter( + (trigger): trigger is { price: string; size: string | undefined } => + trigger.price !== undefined, + ) + .map(async ({ price, size }) => + size === undefined + ? getPositionNotionalUsd() + : this.#resolveOrderNotionalUsd({ + size, + price, + currentPrice: + params.trackingData?.entryPrice ?? + (params.position?.entryPrice === undefined + ? undefined + : Number.parseFloat(params.position.entryPrice)), + }), + ), + ); - const triggerSize = [params.takeProfitSize, params.stopLossSize] - .map((size) => (size === undefined ? NaN : Number.parseFloat(size))) - .filter((size) => Number.isFinite(size) && size > 0) - .reduce( - (largest, size) => - largest === undefined || size > largest ? size : largest, - undefined, - ); - - const partialNotionalUsd = - triggerSize === undefined - ? undefined - : this.#resolveOrderNotionalUsd({ - size: triggerSize.toString(), - price: params.takeProfitPrice ?? params.stopLossPrice, - currentPrice: - params.trackingData?.entryPrice ?? - (params.position?.entryPrice === undefined - ? undefined - : Number.parseFloat(params.position.entryPrice)), - }); - - // An unpriceable partial still falls back to the position rather than + // An unpriceable trigger still falls back to the position rather than // resolving to no notional: over-pricing withholds part of a waiver, // while no notional withholds a bounded one entirely. + const pricedTriggerNotionalsUsd = triggerNotionalsUsd.filter( + (notional): notional is number => + notional !== undefined && Number.isFinite(notional) && notional > 0, + ); const tpslNotionalUsd = - partialNotionalUsd ?? (await positionNotionalUsd()); + pricedTriggerNotionalsUsd.length > 0 + ? Math.max(...pricedTriggerNotionalsUsd) + : await getPositionNotionalUsd(); const feeResolution = await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 112dcb61f85..d42a3fae850 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2726,9 +2726,8 @@ export type PerpsPlatformDependencies = { * Register the current HyperLiquid trading address (CAIP-10) against the * subscription profile, so a later fill can be attributed to it. * - * Optional: `SubscriptionController` exposes no address-registration action - * yet, so a client that cannot perform this simply omits it and the - * controller skips registration. + * Optional compatibility fallback for clients that do not register the + * structural `SubscriptionController:registerAddress` action. */ registerTradingAddress?(caipAccountId: string): Promise; }; diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index c6430bfd023..9f139dd1031 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -30,6 +30,16 @@ import type { import type { SubscriptionControllerGetBenefitsAction } from '@metamask/subscription-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; +/** + * Optional action exposed by clients that own subscription address + * registration. It is structural because older SubscriptionController + * versions do not expose it yet; callers fall back to the injected hook. + */ +export type SubscriptionControllerRegisterAddressAction = { + type: `SubscriptionController:registerAddress`; + handler: (caipAccountId: string) => Promise; +}; + /** * Actions from other controllers that PerpsController is allowed to call. * @@ -41,6 +51,7 @@ import type { TransactionControllerAddTransactionAction } from '@metamask/transa */ export type PerpsControllerAllowedActions = | SubscriptionControllerGetBenefitsAction + | SubscriptionControllerRegisterAddressAction | GeolocationControllerGetGeolocationAction | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 7f123362d67..f00ae89db4d 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1356,6 +1356,21 @@ describe('RewardsIntegrationService', () => { ); }); + it('registers through SubscriptionController for messenger-only clients', async () => { + const registerAddress = jest.fn().mockResolvedValue(undefined); + setupMessengerDefaults({ + 'SubscriptionController:registerAddress': registerAddress, + }); + + await service.registerTradingAddress(mockEvmAccount.address, { + isTestnet: true, + }); + + expect(registerAddress).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:998:0x/u), + ); + }); + it('does not cache a registration that an invalidation superseded', async () => { // A registration issued for profile A must not mark itself complete after // the profile changed: the new profile would then skip its own diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 0b10ce3e4b6..90d929bf9f4 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -298,6 +298,33 @@ describe('TradingService', () => { ); }); + it('prices a skewed Scale ladder from its weighted rung prices', async () => { + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.02', + orderType: 'scale', + scaleMinPrice: '40000', + scaleMaxPrice: '60000', + scaleNumOrders: 3, + scaleSkew: 2, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // The provider's sizes are weighted 1 : 1.5 : 2 across 40000, 50000, + // and 60000. The resulting weighted average is 51111.11 USD. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledTimes(1); + expect( + mockRewardsIntegrationService.resolveFee.mock.calls[0][0], + ).toBeCloseTo((40000 + 50000 * 1.5 + 60000 * 2) * (0.02 / 4.5), 10); + }); + it('prefers a stated USD amount over the Scale ladder bounds', async () => { mockProvider.placeOrder.mockResolvedValue({ success: true }); @@ -2547,9 +2574,33 @@ describe('TradingService', () => { context: { ...mockContext, getPositions: mockGetPositions }, }); - // 0.1 BTC at the 55000 take-profit trigger. + // 0.1 BTC at the 45000 stop-loss trigger. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( - 5500, + 4500, + ); + }); + + it('prices a mixed TP/SL update from the full-size trigger', async () => { + // An omitted trigger size covers the whole position. The provider submits + // that trigger alongside the explicit partial trigger, so the fee must be + // resolved against the larger full-position notional. + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + takeProfitSize: '0.05', + position: mockPosition, + }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 25000, ); }); From e0cd74091bf83d4de6366175a291cf25c7ee964b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 22 Sep 2026 23:55:41 +0800 Subject: [PATCH 21/21] fix(perps): require a strictly cheaper subscription waiver to win the fee A fee tie let the subscription waiver claim the order, which marks the client order ID and spends the remaining allowance without making the order any cheaper than the source it tied. Co-Authored-By: Claude Opus 5 --- packages/perps-controller/CHANGELOG.md | 1 + .../src/services/RewardsIntegrationService.ts | 9 ++-- .../RewardsIntegrationService.test.ts | 43 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2b446d90c94..50e221be91b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. ([#10294](https://github.com/MetaMask/core/pull/10294)) +- Leave a fee tie to the source that already holds it, rather than claiming it for the subscription waiver. A blend that merely matched the winning rate — a 5-bip blend against a 5-bip VIP discount, or a full waiver against a rewards rate already at `0` — was reported as `source: 'subscription'`, which marks the client order ID and spends the remaining allowance without making the order any cheaper. The waiver now has to be strictly cheaper at venue precision to win. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. Repricing a `0` rate is opt-in: a provider that does not report `chargesMetamaskBuilderFee` keeps its own rate, so a `PerpsProvider` implementation written before that field existed cannot gain a MetaMask fee it does not charge. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Reject client order IDs that match the subscription program marker and length but contain non-hex characters from `isSubscriptionProgramCloid`, which no longer treats such a value as a marked ID. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Trust the `fee_reduction_applied` flag only on a client order ID carrying the subscription program marker. The flag byte occupies a position that held random entropy in Scale-ladder client order IDs placed before this release, so reading it on any other client order ID reports roughly half of those historical ladders as fee-waived. As a result `hasFeeReductionAppliedFlag` returns `false` for a marked Scale rung, which keeps its own group marker; Scale attribution needs a correlation other than the client order ID. ([#10294](https://github.com/MetaMask/core/pull/10294)) diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index cc74211a2bb..c49e8c96bd2 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -230,11 +230,12 @@ export class RewardsIntegrationService { Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR), ); - // A strictly cheaper raw blend must also be cheaper once quantized. A tie - // on the raw number is exempt: matching an already-free rate still consumes - // the allowance, so subscription is the honest source there. + // Subscription must be strictly cheaper on the wire to claim the order. A + // tie buys the user nothing — the same rate is already available from the + // source that won — while claiming it marks the cloid and spends the + // remaining allowance, so a tie is left to the other source. That holds at + // any rate, including a rewards discount that already reaches 0 bips. const waiverSurvivesQuantization = - waiver.feeBips === feeBips || toTenthsBps(waiver.feeBips) < toTenthsBps(feeBips); if ( diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index f00ae89db4d..5eaf9cc655b 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -883,6 +883,49 @@ describe('RewardsIntegrationService', () => { expect(resolution.subscription.eligible).toBe(true); }); + it('leaves a blend that only ties a rewards discount to rewards', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 500 })), + ); + // A blend of 10 * (1 - 500/1000) = 5 bips against a 50% VIP/season + // discount worth the same 5 bips. Claiming the tie would mark the cloid + // and spend the allowance for a rate rewards already gives away. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(5000); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBeCloseTo(5, 10); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + expect(resolution.subscriptionCoveredNotionalUsd).toBeUndefined(); + expect(resolution.subscription.eligible).toBe(true); + }); + + it('leaves a full waiver tying an already-free rewards rate to rewards', async () => { + wireSubscription( + jest + .fn() + .mockResolvedValue(createBenefits({ remainingNotionalUsd: 5000 })), + ); + // Rewards already charges nothing, so the waiver cannot make the order + // cheaper — it can only consume the remaining allowance. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(10000); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(0); + expect(resolution.subscriptionWaiverKind).toBeUndefined(); + }); + it('lets a partial subscription blend win when it undercuts rewards', async () => { wireSubscription( jest