Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dd6d872
feat(perps-controller): rework subscription fee waiver for ADR 0064 c…
abretonc7s Sep 17, 2026
f0a1641
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 17, 2026
bf96a73
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 17, 2026
94d285e
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 17, 2026
eadebd5
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 17, 2026
72bae57
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 17, 2026
da707d1
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
aeb0277
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
e56a4b6
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
e88eb7d
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
11466c8
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
21c4ead
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
e2d25c3
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
4628916
fix: address self-review feedback (TAT-3967)
abretonc7s Sep 18, 2026
13149f8
Merge remote-tracking branch 'origin/main' into TAT-3967-feat-rework-…
abretonc7s Sep 18, 2026
4edd59f
fix: address self-review feedback (MetaMask/core#10294)
abretonc7s Sep 18, 2026
5fc1ecf
fix: address CI feedback
abretonc7s Sep 18, 2026
221f5fc
fix: link changelog entries to the PR
abretonc7s Sep 18, 2026
12fb634
Merge remote-tracking branch 'origin/main' into TAT-3967-feat-rework-…
abretonc7s Sep 21, 2026
57c0962
fix(perps): address PR feedback
abretonc7s Sep 21, 2026
e430c9f
fix: address CI feedback
abretonc7s Sep 21, 2026
99f8a1c
fix(perps): address PR review feedback
abretonc7s Sep 22, 2026
8057077
Merge origin/main into TAT-3967-feat-rework-fee-resolver-adr-0064
abretonc7s Sep 22, 2026
469c658
Merge remote-tracking branch 'origin/main' into TAT-3967-feat-rework-…
abretonc7s Sep 22, 2026
e0cd740
fix(perps): require a strictly cheaper subscription waiver to win the…
abretonc7s Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions packages/perps-controller/CHANGELOG.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions packages/perps-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
85 changes: 76 additions & 9 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
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;
}

/**
Expand Down
60 changes: 60 additions & 0 deletions packages/perps-controller/src/constants/perpsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <program marker: 4 bytes> <flags: 1 byte> <entropy: 11 bytes>
* ```
*
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<boolean> {
const provider =
this.#providers.get('hyperliquid') ?? this.#getDefaultProvider();
Expand Down
Loading
Loading