diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1355449cdd6..cf537c5e4db 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add optional `PerpsAccountSigner.getChainId`. When set, HyperLiquid user-signed actions (builder fee approval, withdrawals, transfers) are signed for the chain it returns instead of chain 1, so a wallet that only signs for its connected chain does not switch chains ([#10643](https://github.com/MetaMask/core/pull/10643)) +- Add optional `feeSource` to `FeeCalculationResult`, reporting which fee source (`default`, `rewards` or `subscription`) won the fee resolution the quoted MetaMask fee was priced from, so clients can attribute the discount shown in a fee preview without re-deriving it from the rates ([#10650](https://github.com/MetaMask/core/pull/10650)) + - Absent when the quote was not re-priced from a fee resolution, for example a placement that carries no MetaMask builder fee + - `rewards` can carry a 0% discount, so check `metamaskFeeRate` before presenting it as a reduction ### Changed diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 1ea285dfc2a..7c9eddc6a11 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1807,6 +1807,21 @@ export type FeeCalculationResult = { */ chargesMetamaskBuilderFee?: boolean; + /** + * Fee source whose rate the MetaMask fee above was priced from: the winner + * of the unified fee resolution this quote was computed with. + * + * Absent when the quote was not re-priced from a resolution, e.g. a + * placement that carries no MetaMask builder fee. `subscription` always + * implies a reduced fee. `rewards` can win a tie with the default, so it may + * carry a 0% discount; check `metamaskFeeRate` before presenting it as a + * reduction. + * + * Describes this preview only. The submit path resolves the fee again, so + * the order is attributed to whichever source wins at submission. + */ + feeSource?: PerpsFeeSource; + // Optional detailed breakdown for transparency breakdown?: { baseFeeRate: number; diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index a48b4fcd2bb..41a2fb06cf3 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -373,7 +373,8 @@ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { * 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. + * @returns The quote with its MetaMask component and totals re-priced, tagged + * with the fee source they were priced from. */ export function applyFeeResolution(params: { fees: FeeCalculationResult; @@ -430,6 +431,7 @@ export function applyFeeResolution(params: { ...fees, metamaskFeeRate, feeRate, + feeSource: resolution.source, ...(notional !== undefined && { metamaskFeeAmount: notional * metamaskFeeRate, feeAmount: notional * feeRate, diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index 7aaf3df0a64..51e219cbdf6 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -404,6 +404,24 @@ describe('applyFeeResolution', () => { expect(priced.feeRate).toBeCloseTo(0.0012, 10); expect(priced.metamaskFeeAmount).toBeCloseTo(0.75, 10); expect(priced.feeAmount).toBeCloseTo(1.2, 10); + expect(priced.feeSource).toBe('subscription'); + }); + + it('reports the rewards source when a rewards discount won', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + // A 65% rewards discount off the 10-bip default. + feeBips: 3.5, + discountBips: 6500, + source: 'rewards', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }); + + expect(priced.metamaskFeeRate).toBeCloseTo(0.00035, 10); + expect(priced.feeSource).toBe('rewards'); }); it('zeroes the MetaMask component on a full waiver', () => { @@ -467,6 +485,7 @@ describe('applyFeeResolution', () => { // 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); + expect(priced.feeSource).toBe('default'); }); it.each(['-1000', '0'])( @@ -574,6 +593,7 @@ describe('applyFeeResolution', () => { }); expect(priced.metamaskFeeRate).toBe(0); + expect(priced.feeSource).toBe('subscription'); // The previously quoted amounts are left as the provider reported them. expect(priced.metamaskFeeAmount).toBe(fees.metamaskFeeAmount); });