diff --git a/README.md b/README.md index 8151640a297..c0fea939dfc 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,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 6dcfc9d9a0a..50e221be91b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,12 +9,66 @@ 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. ([#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. The injected hook remains a fallback for clients that do not provide `SubscriptionController:registerAddress`. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Add optional `id` field to `PerpsMarketData` type, populated from the v3 Terminal snapshot endpoint ([#10356](https://github.com/MetaMask/core/pull/10356)) ### Changed +- **BREAKING:** `PerpsControllerAllowedActions` now includes `SubscriptionController:getBenefits` and the structural `SubscriptionController:registerAddress` action, 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 include both action types before it will build; the registration handler accepts a CAIP-10 string and returns `Promise`. 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. ([#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. ([#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. ([#10294](https://github.com/MetaMask/core/pull/10294)) + - 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. - Bump `GLOBAL_SNAPSHOT_SCHEMA_VERSION` from `2` to `3` to consume the v3 `/perpetuals` Terminal endpoint, which adds a stable `id` per market ([#10356](https://github.com/MetaMask/core/pull/10356)) +### 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. ([#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. ([#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)) +- 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)) +- Price each TP/SL trigger from its own price and absolute size, using the full position size when a trigger size is omitted. If any included trigger cannot be priced, withhold a bounded waiver for the batch. ([#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)) +- 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)) +- 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.3.0] ### Fixed diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 7f7886d0421..608b9f8ac34 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" @@ -87,6 +91,7 @@ "@metamask/network-controller": "^37.0.0", "@metamask/profile-sync-controller": "^32.2.0", "@metamask/remote-feature-flag-controller": "^7.0.0", + "@metamask/subscription-controller": "^9.1.0", "@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-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 6cb437da976..7c0e6daaa3e 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -907,10 +907,17 @@ 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 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 = { type: `PerpsController:approveSubscriptionBuilderFee`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 384a3aafe1a..10044831789 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1277,6 +1277,39 @@ 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(); + // 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, { + isTestnet: this.state.isTestnet, + }) + .catch(() => { + /* never blocks an account switch */ + }); + } + }; + this.messenger.subscribe( + 'AccountsController:selectedAccountChange', + forgetRegisteredTradingAddresses, + ); + this.messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + forgetRegisteredTradingAddresses, + ); + this.providers = new Map(); // Migrate old persisted data without accountAddress @@ -5779,27 +5812,61 @@ 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(); - const waiverStatus = - this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); + + // 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, { + isTestnet: this.state.isTestnet, + }) + .catch(() => { + /* never blocks a fee preview */ + }); + } + + // 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); + // 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, + 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 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 { - 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 true; } /** diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 35768289a7e..09ed6ae2f4b 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -416,6 +416,66 @@ 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 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 = { + /** + * Reserved program marker, 4 bytes as lowercase hex without the `0x`. + * + * 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: '00000100', + + /** 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..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. * @@ -996,6 +1011,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..265b1b7b764 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,10 @@ import { queryStandaloneOpenOrders, } from '../utils/standaloneInfoClient.js'; import { parseBoundedNonNegativeDecimal } from '../utils/stringParseUtils.js'; +import { + markSubscriptionCloid, + quantizeBuilderFeeTenthsBps, +} from '../utils/subscriptionFeeWaiver.js'; // getStreamManagerInstance removed: use this.#deps.streamManager instead const HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE = { @@ -925,13 +930,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) @@ -992,6 +1002,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. */ @@ -4353,6 +4373,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 +5351,7 @@ export class HyperLiquidProvider implements PerpsProvider { try { const result = await exchangeClient.order({ - orders, + orders: this.#applySubscriptionCloid(orders), grouping, ...(builder && { builder }), }); @@ -6234,17 +6261,34 @@ 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], + })), + // 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), + ); + // 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, @@ -6510,6 +6554,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 @@ -6563,6 +6611,7 @@ export class HyperLiquidProvider implements PerpsProvider { size: formattedSize, reduceOnly: params.reduceOnly ?? false, builder, + marksSubscriptionCloid, exchangeClient: placingClient, }); break; @@ -6618,6 +6667,7 @@ export class HyperLiquidProvider implements PerpsProvider { intervalMs, lastSnapshotSizeRefreshAt: 0, builder, + marksSubscriptionCloid, deadline: params.chaseMaxDurationMs === undefined ? Number.POSITIVE_INFINITY @@ -6755,6 +6805,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` @@ -6768,21 +6821,26 @@ export class HyperLiquidProvider implements PerpsProvider { size: string; reduceOnly: boolean; builder?: BuilderOrderContext; + marksSubscriptionCloid?: boolean; exchangeClient: ExchangeClient; }): Promise { const result = await params.exchangeClient.order({ - orders: [ - { - 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 }), }); @@ -7165,6 +7223,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(), @@ -8535,18 +8594,19 @@ 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); } /** * 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 +8614,79 @@ 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 operation in flight actually carries a subscription reduction. + * + * 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' && + this.#getDiscountedBuilderFee() < BUILDER_FEE_CONFIG.MaxFeeTenthsBps + ); + } - 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. + * + * 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. + * @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[] { + // 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; } - return { b: defaultBuilder, f: this.#getDiscountedBuilderFee() }; + return orders.map((order) => ({ + ...order, + c: markSubscriptionCloid({ + clientOrderId: order.c ?? undefined, + isGenerated: + order.c !== undefined && Boolean(generatedCloids?.has(order.c)), + entropy: uuidv4().replace(/-/gu, ''), + }), + })); } /** @@ -8873,7 +8976,11 @@ 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 result = await exchangeClient.modify({ oid: @@ -9434,7 +9541,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 +10159,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 +10314,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 }), @@ -14608,6 +14717,7 @@ export class HyperLiquidProvider implements PerpsProvider { // MetaMask fees metamaskFeeRate, metamaskFeeAmount, + chargesMetamaskBuilderFee, }; this.#deps.debugLogger.log('Final Fee Calculation Result', { @@ -14997,6 +15107,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/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 d496f409a18..55a71018ab4 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,24 @@ 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, + chargesBuilderFee: fees.chargesMetamaskBuilderFee, + }); + // 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..c49e8c96bd2 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -1,10 +1,15 @@ +import type { SubscriptionBenefitsResponse } from '@metamask/subscription-controller'; + import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, + HYPERLIQUID_MAINNET_CHAIN_ID, + HYPERLIQUID_TESTNET_CHAIN_ID, } from '../constants/hyperLiquidConfig.js'; import { PERPS_CONSTANTS, SUBSCRIPTION_BENEFITS_CACHE, + SUBSCRIPTION_FEE_WAIVER_FLAG, } from '../constants/perpsConfig.js'; import type { PerpsFeeResolution, @@ -17,6 +22,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 { + quantizeBuilderFeeTenthsBps, + resolveSubscriptionWaiverRate, +} from '../utils/subscriptionFeeWaiver.js'; /** * Default MetaMask builder fee, in basis points. @@ -25,6 +34,9 @@ import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.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. */ @@ -88,6 +100,21 @@ 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(); + + /** + * 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. + */ + #messengerBenefitsAnswered = false; + /** * Create a new RewardsIntegrationService instance * @@ -125,10 +152,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 +169,22 @@ 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. + * + * 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. */ - 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 +204,49 @@ 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. + // + // 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), + ); + + // 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 = + toTenthsBps(waiver.feeBips) < toTenthsBps(feeBips); + + if ( + waiver.applies && + waiver.feeBips <= feeBips && + waiverSurvivesQuantization + ) { + feeBips = waiver.feeBips; source = 'subscription'; + subscriptionWaiverKind = waiver.kind === 'partial' ? 'partial' : 'full'; + subscriptionCoveredNotionalUsd = waiver.coveredNotionalUsd; } const discountBips = @@ -178,11 +260,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, + }; } /** @@ -194,7 +286,15 @@ 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' }; + } + + // 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' }; } @@ -214,6 +314,59 @@ export class RewardsIntegrationService { return evaluateFeeWaiverGate(snapshot.benefits); } + /** + * Whether this client has any way to read subscription benefits. + * + * 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 + * 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:getBenefits'); + } catch { + return false; + } + } + + /** + * 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. * @@ -224,10 +377,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; @@ -247,7 +400,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 @@ -281,6 +434,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', @@ -290,16 +446,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 source.getPerpsBenefits(); + const benefits = await this.#getPerpsBenefits(); if (epoch !== this.#benefitsEpoch) { // Invalidated while this read was in flight: it belongs to a previous @@ -348,6 +500,219 @@ export class RewardsIntegrationService { } } + /** + * Read subscription benefits, preferring the messenger over the DI callback. + * + * 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". + * + * @returns The benefits payload, or null when there is none to report. + */ + async #getPerpsBenefits(): 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: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).then(adaptSubscriptionBenefits); + } + } 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 (isNotSubscribedRejection(error)) { + // Definitive "not entitled", even thrown synchronously. + return null; + } + // 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 + // back to. + } + + const fallback = this.#deps.subscription; + + if (pending) { + 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 + // 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. + } + } + + 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(); + } + + /** + * 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. + * + * 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. + * @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, + options?: { isTestnet?: boolean }, + ): Promise { + 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 + // 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, + 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; + } + + // 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. + await registration; + if (epoch !== this.#benefitsEpoch) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Trading address registration superseded', + { caipAccountId }, + ); + return; + } + this.#registeredTradingAddresses.add(caipAccountId); + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Trading address registered', + { caipAccountId }, + ); + } catch (error) { + // 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', + { + 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. * @@ -458,6 +823,99 @@ 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". + * + * `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. + * + * 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; + // `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 && hasPerpsBenefit, + 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. * 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/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 52b8045e1e6..388459527aa 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, @@ -33,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'; @@ -600,7 +604,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, @@ -774,20 +780,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, @@ -1164,15 +1181,279 @@ export class TradingService { } /** - * Calculate fee discount with performance measurement - * Uses controller dependencies injected via setControllerDependencies() - * Helper method for placeOrder orchestration + * Resolve the total USD notional a batch close will submit. * - * @returns The result of the operation. + * 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.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; + provider: PerpsProvider; + }): Promise { + const { params, provider } = options; + + try { + // 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. + // `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.closeAll !== true && 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; + } + } + + /** + * 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. */ - async #calculateFeeDiscountWithMeasurement(): Promise< - PerpsFeeResolution | undefined - > { + #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. + * + * 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. + * + * `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.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. + * @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. + * @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: { + size?: string; + usdAmount?: string; + price?: string; + triggerPrice?: string; + currentPrice?: number; + priceAtCalculation?: number; + scaleMinPrice?: string; + scaleMaxPrice?: string; + scaleNumOrders?: number; + scaleSkew?: 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); + // 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); + // 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 + : Number.parseFloat(params.scaleMinPrice); + const scaleMaxPrice = + params.scaleMaxPrice === undefined + ? undefined + : Number.parseFloat(params.scaleMaxPrice); + const scaleWeightedPrice = + scaleMinPrice !== undefined && + scaleMaxPrice !== undefined && + Number.isFinite(scaleMinPrice) && + Number.isFinite(scaleMaxPrice) && + scaleMinPrice > 0 && + scaleMaxPrice > 0 + ? ((): 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, + scaleWeightedPrice, + params.priceAtCalculation, + params.currentPrice, + ] + .filter( + (candidate): candidate is number => + candidate !== undefined && + Number.isFinite(candidate) && + candidate > 0, + ) + .at(0); + + 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 { // Check if controller dependencies are available if (!this.#controllerDeps) { this.#deps.debugLogger.log( @@ -1185,8 +1466,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 +1487,8 @@ export class TradingService { { discountBips: resolution.discountBips, source: resolution.source, + orderNotionalUsd, + subscriptionWaiverKind: resolution.subscriptionWaiverKind, duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, }, ); @@ -1251,7 +1537,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({ @@ -1754,6 +2042,8 @@ export class TradingService { position = await this.#loadPositionData({ symbol: params.symbol, context, + provider, + providerId: params.providerId, }); // Emit submitted event before the provider round-trip @@ -1767,8 +2057,23 @@ export class TradingService { }), }); - // Calculate fee discount with measurement - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + // 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, + // 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 result = await this.#withFeeDiscount({ @@ -1919,7 +2224,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, provider }), + ); operationResult = await this.#withFeeDiscount({ provider, @@ -2127,8 +2436,54 @@ export class TradingService { ...this.#buildAttributionProperties(params.trackingData), }); - // Get fee discount from rewards - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + // The batch shares one builder fee. Price each included trigger at its + // own level and size, then use the largest notional. An omitted size + // covers the absolute position size, including for short positions. + let positionPromise: Promise | undefined; + const getPosition = (): Promise => + (positionPromise ??= params.position + ? Promise.resolve(params.position) + : this.#loadPositionData({ + symbol: params.symbol, + context, + provider, + providerId: params.providerId, + })); + + 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 }) => { + const triggerSize = size ?? (await getPosition())?.size; + return this.#resolveOrderNotionalUsd({ + size: + triggerSize === undefined + ? undefined + : Math.abs(Number.parseFloat(triggerSize)).toString(), + price, + }); + }), + ); + + // A known sibling cannot stand in for a trigger whose size is unknown. + // Passing undefined withholds a bounded waiver for the entire batch. + const pricedTriggerNotionalsUsd = triggerNotionalsUsd.filter( + (notional): notional is number => + notional !== undefined && Number.isFinite(notional) && notional > 0, + ); + const tpslNotionalUsd = + pricedTriggerNotionalsUsd.length > 0 && + pricedTriggerNotionalsUsd.length === triggerNotionalsUsd.length + ? Math.max(...pricedTriggerNotionalsUsd) + : undefined; + const feeResolution = + await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd); // Execute with fee discount management result = await this.#withFeeDiscount({ @@ -2436,7 +2791,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/types/index.ts b/packages/perps-controller/src/types/index.ts index d89d094c425..41405bb946f 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1694,6 +1694,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; @@ -1705,8 +1716,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; }; @@ -1810,6 +1826,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 = { @@ -2087,10 +2118,29 @@ 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; - /** 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 @@ -2678,6 +2728,15 @@ 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 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 e547d362181..9f139dd1031 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -27,12 +27,31 @@ import type { RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerStateChangeEvent, } from '@metamask/remote-feature-flag-controller'; +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. + * + * `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 = + | SubscriptionControllerGetBenefitsAction + | 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/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/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts new file mode 100644 index 00000000000..a48b4fcd2bb --- /dev/null +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -0,0 +1,438 @@ +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. + * + * 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. + * + * @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' }; + } + + // 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: false, feeBips: maxFeeBips, kind: 'none' }; + } + + if (remaining >= orderNotionalUsd) { + return { + applies: true, + feeBips: 0, + kind: 'full', + coveredNotionalUsd: orderNotionalUsd, + }; + } + + return { + applies: true, + feeBips: maxFeeBips * (1 - remaining / orderNotionalUsd), + kind: 'partial', + coveredNotionalUsd: remaining, + }; +} + +/** + * 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; + +/** 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. + * + * @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(); + // 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; + } + return Number.parseInt( + normalized.slice(FLAG_BYTE_START, FLAG_BYTE_START + 2), + 16, + ); +} + +/** + * 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. + * + * **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. + * + * 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 + * subscription program marker. + */ +export function hasFeeReductionAppliedFlag( + clientOrderId: string | null | undefined, +): boolean { + if (!isSubscriptionProgramCloid(clientOrderId)) { + return false; + } + 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(); + // 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 && + CLOID_PATTERN.test(normalized) && + 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. Three cases: + * + * - **No existing cloid** — the leading bytes become + * {@link SUBSCRIPTION_CLOID_CONFIG.ProgramId} and the rest is fresh entropy. + * - **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 + * 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. + * + * 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, isGenerated = false, entropy } = params; + const flags = SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied.toString( + 16, + ).padStart(2, '0'); + + let body: string; + 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'); + } + return clientOrderId as Hex; + } else if (clientOrderId?.length === CLOID_HEX_LENGTH) { + const existing = clientOrderId.slice(2).toLowerCase(); + // 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, + )}`; + } 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; +} + +/** + * 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. + * + * 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. + * @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; + chargesBuilderFee?: boolean; +}): FeeCalculationResult { + const { fees, resolution, amount, chargesBuilderFee } = params; + + 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. + // + // 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; + } + + // 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(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 > 0 + ? 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..4852c3a225f 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,197 @@ describe('PerpsController', () => { refresh.mockRestore(); }); + it('resolves the preview fee against the order notional', 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); + + // 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 + ).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', + }); + + // 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(); + }); + + 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); + 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. + 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, 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 () => { + 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. It resolves `true` + // because nothing needs approving; `false` would read as setup failure. + await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + 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 c1118e3572f..75c37524b8a 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..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 @@ -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,196 @@ 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('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); + + 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..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 @@ -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, @@ -25,6 +26,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 +35,10 @@ import { validateOrderParams, validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; +import { + hasFeeReductionAppliedFlag, + readSubscriptionCloidFlags, +} from '../../../src/utils/subscriptionFeeWaiver.js'; import { createMockPosition } from '../../helpers/providerMocks.js'; import { createDeferred, @@ -3441,6 +3447,71 @@ 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 in its flag byte... + cloids.forEach((cloid: string) => { + 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); + }); + + 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 }) => { + // 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, + ); + }); + }); + it('submits the provider preview prices for fractional bounds', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, @@ -8673,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/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index dfc584573e1..7ae9ad787b2 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,141 @@ 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('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); + + 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 () => { + 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..5eaf9cc655b 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. * @@ -335,7 +375,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 +385,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 +439,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({ @@ -416,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()), @@ -523,7 +608,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 +783,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,11 +798,689 @@ 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 () => { + 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('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 + .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('withholds a bounded allowance 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 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'); + 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( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': 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('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( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': 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('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( + createBenefitsResponse({ remainingMicroUsd: 250_000_000 }), + ); + setupMessengerDefaults({ + 'SubscriptionController:getBenefits': 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('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('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('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(); + ( + 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()), + ); + + 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 () => { + // `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(registerTradingAddress).toHaveBeenCalledTimes(1); + // HyperLiquid mainnet (`eip155:999`), not the wallet's selected network. + expect(registerTradingAddress).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:999:0x/u), + ); + + // Account switch: the new address has to announce itself. + service.resetRegisteredTradingAddresses(); + await service.registerTradingAddress(mockEvmAccount.address); + + 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('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 + // 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 + // 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 () => { + 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(); }); - }); - 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/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index dbdf3adfc60..9ee86c01839 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,234 @@ 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('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('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('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 }); + + 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 }); + + 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, @@ -1645,6 +1874,84 @@ 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('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 }); + + 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', @@ -1864,6 +2171,124 @@ 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, + ); + }); + + 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', @@ -2105,6 +2530,144 @@ 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 45000 stop-loss trigger. + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 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( + 22500, + ); + }); + + it('prices a whole-position TP/SL update from its trigger price', async () => { + // The whole position is valued at the trigger price, not the mark 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( + 27500, + ); + }); + + it.each(['0.5', '-0.5'])( + 'prices an omitted TP size from absolute position size %s', + async (size) => { + mockGetPositions.mockResolvedValue([{ ...mockPosition, size }]); + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '45000', + stopLossSize: '0.05', + }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + 30000, + ); + expect(mockGetPositions).toHaveBeenCalledTimes(1); + }, + ); + + it('withholds a bounded waiver when an omitted trigger size cannot be priced', async () => { + mockGetPositions.mockResolvedValue([]); + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '45000', + stopLossSize: '0.05', + }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + undefined, + ); + }); + it('updates TP/SL successfully without fee discount', async () => { const params: UpdatePositionTPSLParams = { symbol: 'BTC', 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..7aaf3df0a64 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -0,0 +1,580 @@ +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, + quantizeBuilderFeeTenthsBps, + 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])( + '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, + }), + ).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' }); + }, + ); +}); + +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('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' }); + + 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, + isGenerated: true, + 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'); + // 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('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({ + 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', () => { + 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, + isGenerated: true, + 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('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('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. + 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('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('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.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' }), + ).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', + 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 + // 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', + chargesBuilderFee: true, + }); + + 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, + 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); + }); +}); 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/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" } ] } diff --git a/yarn.lock b/yarn.lock index 72cd6a6ce87..aeb1093376b 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.2.0" "@metamask/remote-feature-flag-controller": "npm:^7.0.0" + "@metamask/subscription-controller": "npm:^9.1.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/transaction-controller": "npm:^70.1.0" "@metamask/utils": "npm:^12.0.0"