diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2b20d0dfb13..e9607d47faa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +- Add optional `metamaskFeeDiscountBips` and `undiscountedMetamaskFeeRate` to `FeeCalculationResult`, so clients can show the original MetaMask fee next to the discounted one without the default builder fee constant ([#10683](https://github.com/MetaMask/core/pull/10683)) + - `metamaskFeeDiscountBips` is the discount the winning fee source applied, in basis points off the default builder fee; `undiscountedMetamaskFeeRate` is the MetaMask fee rate before it + - `metamaskFeeDiscountBips` is `0` when no reduction applied: when `default` won, or when `rewards` won with a 0% discount. A `feeSource` other than `default` does not imply a discount + - `metamaskFeeRate` is already discounted; do not apply `metamaskFeeDiscountBips` to it again + - Absent whenever `feeSource` is absent, for example a placement that carries no MetaMask builder fee ### Changed diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7c9eddc6a11..127c37352a5 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1793,7 +1793,7 @@ export type FeeCalculationResult = { protocolFeeAmount?: number; // Protocol fee amount in USD // MetaMask builder/revenue fee - metamaskFeeRate?: number; // MetaMask fee rate (e.g., 0.001 for 0.1%), undefined when unavailable + metamaskFeeRate?: number; // MetaMask fee rate (e.g., 0.001 for 0.1%), undefined when unavailable. Already discounted: do not apply `metamaskFeeDiscountBips` to it again metamaskFeeAmount?: number; // MetaMask fee amount in USD /** @@ -1822,6 +1822,33 @@ export type FeeCalculationResult = { */ feeSource?: PerpsFeeSource; + /** + * Discount the `feeSource` winner applied, in basis points off the default + * MetaMask builder fee (e.g. 6500 for 65% off). `0` when no reduction + * applied: when `default` won, or when `rewards` won with a 0% discount. A + * `feeSource` other than `default` therefore does not imply a discount; check + * this value instead. + * + * Already applied: `metamaskFeeRate` is `undiscountedMetamaskFeeRate` + * reduced by this discount and floored to the tenth of a basis point the + * venue charges. Do not discount `metamaskFeeRate` again. The floor can make + * the actual reduction slightly larger than this figure, so derive savings + * amounts from the two rates. + * + * Absent under the same rule as `feeSource`. + */ + metamaskFeeDiscountBips?: number; + + /** + * MetaMask fee rate before `metamaskFeeDiscountBips` was applied, as a + * decimal (e.g. 0.001 for 0.1%). Equal to `metamaskFeeRate` when no discount + * applied. Use it to show the original fee next to the discounted one; + * `metamaskFeeRate` is the rate the order is charged. + * + * Absent under the same rule as `feeSource`. + */ + undiscountedMetamaskFeeRate?: number; + // 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 41a2fb06cf3..21e8206bd03 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -374,7 +374,8 @@ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { * 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, tagged - * with the fee source they were priced from. + * with the fee source they were priced from, the discount that source applied, + * and the MetaMask fee rate before that discount. */ export function applyFeeResolution(params: { fees: FeeCalculationResult; @@ -412,6 +413,10 @@ export function applyFeeResolution(params: { // fill rather than the unfloored fraction the discount implies. const metamaskFeeRate = quantizeBuilderFeeTenthsBps(discountBips) / BUILDER_FEE_TENTHS_BPS_PER_UNIT; + // Same expression with no discount, so the two rates are equal whenever the + // default source wins. + const undiscountedMetamaskFeeRate = + quantizeBuilderFeeTenthsBps(0) / 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 @@ -432,6 +437,8 @@ export function applyFeeResolution(params: { metamaskFeeRate, feeRate, feeSource: resolution.source, + metamaskFeeDiscountBips: discountBips, + undiscountedMetamaskFeeRate, ...(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 51e219cbdf6..b82da2bfd2a 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -578,6 +578,9 @@ describe('applyFeeResolution', () => { expect(priced.metamaskFeeRate).toBe(0.001); expect(priced.metamaskFeeAmount).toBe(1); + // Nor does it inherit the waiver's discount. + expect(priced.metamaskFeeDiscountBips).toBe(0); + expect(priced.undiscountedMetamaskFeeRate).toBe(0.001); }); it('re-prices rates without amounts when no notional was supplied', () => { @@ -597,4 +600,104 @@ describe('applyFeeResolution', () => { // The previously quoted amounts are left as the provider reported them. expect(priced.metamaskFeeAmount).toBe(fees.metamaskFeeAmount); }); + + it('reports a 0-bip discount and an undiscounted rate equal to the quoted rate when the default source won', () => { + // The provider rate carries a discount a concurrent submit left behind, so + // the undiscounted rate must not be read from 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', + }); + + expect(priced.feeSource).toBe('default'); + expect(priced.metamaskFeeDiscountBips).toBe(0); + expect(priced.undiscountedMetamaskFeeRate).toBe(0.001); + expect(priced.undiscountedMetamaskFeeRate).toBe(priced.metamaskFeeRate); + }); + + it('reports the rewards discount and the undiscounted rate it was taken off', () => { + // 3333 bips off the 10-bip default is 6.667 bips, which the venue floors to + // 6.6: the quoted rate is undiscounted x (1 - d), floored to a tenth of a bip. + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 6.667, + discountBips: 3333, + source: 'rewards', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }); + + expect(priced.feeSource).toBe('rewards'); + expect(priced.metamaskFeeDiscountBips).toBe(3333); + expect(priced.undiscountedMetamaskFeeRate).toBe(0.001); + expect(priced.metamaskFeeRate).toBe(0.00066); + }); + + it('reports a 0-bip discount when rewards wins a tie with the default', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 10, + discountBips: 0, + source: 'rewards', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }); + + expect(priced.feeSource).toBe('rewards'); + expect(priced.metamaskFeeDiscountBips).toBe(0); + expect(priced.metamaskFeeRate).toBe(priced.undiscountedMetamaskFeeRate); + }); + + it('reports the subscription discount at the rate a full waiver is charged', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: createStatus(), + subscriptionWaiverKind: 'full', + }, + amount: '1000', + }); + + expect(priced.feeSource).toBe('subscription'); + expect(priced.metamaskFeeDiscountBips).toBe(10000); + expect(priced.undiscountedMetamaskFeeRate).toBe(0.001); + expect(priced.metamaskFeeRate).toBe(0); + }); + + it('reports the subscription discount at the rate a partial waiver is charged', () => { + // 333 of a 1000 USD order covered: 6.67 bips blended, a 3330-bip discount. + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 6.67, + discountBips: 3330, + source: 'subscription', + subscription: createStatus({ remainingNotionalUsd: 333 }), + subscriptionWaiverKind: 'partial', + }, + amount: '1000', + }); + + expect(priced.feeSource).toBe('subscription'); + expect(priced.metamaskFeeDiscountBips).toBe(3330); + expect(priced.undiscountedMetamaskFeeRate).toBe(0.001); + // The submit path charges quantizeBuilderFeeTenthsBps(d) tenths of a bip. + expect(priced.metamaskFeeRate).toBe( + quantizeBuilderFeeTenthsBps(3330) / 100_000, + ); + expect(priced.metamaskFeeRate).toBe(0.00066); + }); });