Repository navigation
feat(perps-controller): report the winning fee source on fee quotes - #10650
Merged
Merged
Conversation
tanguyenvn
temporarily deployed
to
default-branch
October 1, 2026 09:37 — with
GitHub Actions
Inactive
Contributor
Author
|
@metamaskbot publish-previews |
Contributor
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
geositta
previously approved these changes
Oct 1, 2026
geositta
left a comment
Contributor
There was a problem hiding this comment.
Looks good to merge. Thanks for documenting that a rewards source can represent no reduction, and submission resolves the fee again.
4 tasks
# Conflicts: # packages/perps-controller/CHANGELOG.md
geositta
approved these changes
Oct 2, 2026
This was referenced Oct 6, 2026
Merged
Naz-Ovh
pushed a commit
to 0x-fork/metamask-core
that referenced
this pull request
Oct 7, 2026
…otes (MetaMask#10683) ## Explanation `PerpsController.calculateFees` re-prices the MetaMask fee from the unified fee resolution and, since MetaMask#10650, reports which source won (`feeSource`). It still doesn't say how big the discount was. A client that wants to show the original fee struck through, or a "VIP x%" badge, has to rebuild the discount from `metamaskFeeRate` and the builder-fee constant, which is what Mobile does today. This adds two optional fields to `FeeCalculationResult`, set in `applyFeeResolution` next to `feeSource`: - `metamaskFeeDiscountBips`: the discount the winning source applied, in basis points off the default builder fee. `0` when no reduction applied: when `default` won, or when `rewards` won with a 0% discount. A `feeSource` other than `default` doesn't imply a discount; check this value. - `undiscountedMetamaskFeeRate`: the MetaMask fee rate before that discount. Both come from the same `discountBips` that prices `metamaskFeeRate`, so they can't disagree with it. `metamaskFeeRate` equals the undiscounted rate times `(1 - d)`, floored to the tenth of a basis point HyperLiquid charges (the submit path uses the same function). The undiscounted rate goes through the same quantizer at a zero discount, so it equals `metamaskFeeRate` whenever `default` wins. The fields follow the `feeSource` rule: absent when the quote wasn't re-priced, for example a TWAP, which carries no builder fee. The docs say `metamaskFeeRate` is already discounted and must not be discounted again. Proof, on HyperLiquid testnet for fixture account dev3, reading `calculateFees` from the `PerpsController` that the mm-harness core adapter builds from this branch (1000 USD ETH order): | Case | `feeSource` | `metamaskFeeDiscountBips` | `undiscountedMetamaskFeeRate` | `metamaskFeeRate` | |------|-------------|---------------------------|-------------------------------|-------------------| | No discount | `default` | 0 | 0.001 | 0.001 | | Subscription, full waiver | `subscription` | 10000 | 0.001 | 0 | | Subscription, 333 of 1000 USD covered | `subscription` | 3330 | 0.001 | 0.00066 | | TWAP | absent | absent | absent | 0 | Subscription benefits were supplied through `SubscriptionController:getBenefits`, as a client wires them. The same recipe fails on `main`. The rewards source is covered by unit tests: the harness's headless controller has no rewards dependency to inject a discount into. Client follow-up: Extension (`usePerpsOrderFees`, the close-all modal) and Mobile (`usePerpsCloseAllCalculations`) still multiply `metamaskFeeRate` by a locally fetched discount. Since Core already applies it, they should read these fields and drop the local multiplier once this is released. ## References - https://consensyssoftware.atlassian.net/browse/TAT-4059 - Builds on MetaMask#10650 (`feeSource`); ships in the same perps-controller release - Mobile derivation to replace: https://consensyssoftware.atlassian.net/browse/TAT-4055 ## **Validation Recipe** <details><summary>recipe.json (0 steps — Perps fee quote reports the applied MetaMask fee discount)</summary> ```json { "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "Perps fee quote reports the applied MetaMask fee discount", "description": "TAT-4059. Reads live HyperLiquid testnet fee quotes for fixture account dev3 through the PerpsController the mm-harness core adapter builds from this checkout, and checks metamaskFeeDiscountBips / undiscountedMetamaskFeeRate for the default, subscription (full and partial) and TWAP cases. Read-only: no order is placed and nothing is signed, so there is no venue teardown. Subscription cases supply SubscriptionController:getBenefits through the controller messenger, as a client does. The rewards source and the per-source unit tests run as a focused Jest node. Command nodes run the task-local driver with tsx because the core adapter's controller actions require node_modules/.bin/tsx, which this checkout no longer ships (MetaMask#10564).", "paramsSchema": { "type": "object", "additionalProperties": false, "properties": { "account_name": { "type": "string", "default": "dev3", "description": "Wallet fixture account to quote for. Never dev1 (reserved for other live runs)." }, "output_dir": { "type": "string", "description": "Unique directory for this run's quote JSON." }, "account_address": { "type": "string", "default": "0x719638ec3d32c8C87A521561D219C122bFCB3811", "description": "Public address of the selected fixture account; checked against metamask.wallet.list_accounts before any quote." } } }, "proofTargets": [ { "id": "AC1", "claim": "With no discount, a quote reports feeSource default, metamaskFeeDiscountBips 0, and undiscountedMetamaskFeeRate equal to metamaskFeeRate." }, { "id": "AC2", "claim": "With a rewards discount d, the quote reports feeSource rewards, metamaskFeeDiscountBips d, and metamaskFeeRate equal to the quantized undiscountedMetamaskFeeRate x (1 - d)." }, { "id": "AC3", "claim": "With a full or partial subscription waiver, the fields match the blended rate the order is charged." }, { "id": "AC4", "claim": "A placement with no builder fee (TWAP) leaves the fields absent." }, { "id": "AC5", "claim": "Proven live through the core adapter on HyperLiquid testnet (fee quote read via the controller), plus unit tests for each source." }, { "id": "AC6", "claim": "CHANGELOG entry under Unreleased." } ], "workflow": { "entry": "select-account", "nodes": { "select-account": { "action": "metamask.wallet.list_accounts", "account_name": "{{params.account_name}}", "scope": "selected", "intent": "Resolve the public address of the selected fixture account without loading any other account.", "next": "assert-account" }, "assert-account": { "action": "assert_output", "source": "select-account", "assert": { "path": "$.count", "operator": "eq", "value": 1 }, "intent": "Require exactly one selected fixture account.", "next": "assert-account-name" }, "assert-account-name": { "action": "assert_output", "source": "select-account", "assert": { "path": "$.accounts[0].name", "operator": "eq", "value": "dev3" }, "intent": "Require the selected account to be dev3, so no quote runs against dev1.", "next": "assert-account-address" }, "assert-account-address": { "action": "assert_output", "source": "select-account", "assert": { "path": "$.accounts[0].address", "operator": "eq", "value": "{{params.account_address}}" }, "intent": "Bind the address the quotes use to the fixture's dev3 entry.", "next": "prepare-output" }, "prepare-output": { "action": "command", "cmd": "mkdir -p \"{{params.output_dir}}\"", "timeout_ms": 30000, "intent": "Create this run's evidence directory so each command can write its log there.", "next": "read-account" }, "read-account": { "action": "command", "timeout_ms": 180000, "intent": "Read dev3's HyperLiquid testnet account through the adapter's read_account code, recording its balance. The quotes below are read-only and need no collateral.", "next": "assert-account-read" }, "assert-account-read": { "action": "assert_json", "path": "{{params.output_dir}}/account.json", "assert": { "all": [ { "path": "$.network", "operator": "eq", "value": "testnet" }, { "path": "$.source", "operator": "eq", "value": "perps-controller-standalone" } ] }, "intent": "Confirm the account read went through the controller on testnet for the selected address.", "next": "quote-default" }, "quote-default": { "action": "command", "timeout_ms": 240000, "intent": "Quote a 1000 USD ETH market order for dev3 with no rewards or subscription source.", "next": "assert-default" }, "assert-default": { "action": "assert_json", "path": "{{params.output_dir}}/quote-default.json", "assert": { "all": [ { "path": "$.isTestnet", "operator": "eq", "value": true }, { "path": "$.quote.feeSource", "operator": "eq", "value": "default" }, { "path": "$.quote.metamaskFeeDiscountBips", "operator": "eq", "value": 0 }, { "path": "$.quote.undiscountedMetamaskFeeRate", "operator": "eq", "value": 0.001 }, { "path": "$.quote.metamaskFeeRate", "operator": "eq", "value": 0.001 }, { "path": "$.checks.undiscountedEqualsRate", "operator": "eq", "value": true }, { "path": "$.allChecksPass", "operator": "eq", "value": true } ] }, "proves": [ "AC1", "AC5" ], "intent": "AC1: a quote with no discount reports the default source, a 0-bip discount, and an undiscounted rate equal to the quoted rate.", "next": "quote-subscription-full" }, "quote-subscription-full": { "action": "command", "timeout_ms": 240000, "intent": "Quote the same order while SubscriptionController reports 5000 USD of waiver allowance left.", "next": "assert-subscription-full" }, "assert-subscription-full": { "action": "assert_json", "path": "{{params.output_dir}}/quote-subscription-full.json", "assert": { "all": [ { "path": "$.isTestnet", "operator": "eq", "value": true }, { "path": "$.quote.feeSource", "operator": "eq", "value": "subscription" }, { "path": "$.quote.metamaskFeeDiscountBips", "operator": "eq", "value": 10000 }, { "path": "$.quote.undiscountedMetamaskFeeRate", "operator": "eq", "value": 0.001 }, { "path": "$.quote.metamaskFeeRate", "operator": "eq", "value": 0 }, { "path": "$.allChecksPass", "operator": "eq", "value": true } ] }, "proves": [ "AC3", "AC5" ], "intent": "AC3 full waiver: a 10000-bip discount off the 0.001 undiscounted rate, quoted at the 0 rate the order is charged.", "next": "quote-subscription-partial" }, "quote-subscription-partial": { "action": "command", "timeout_ms": 240000, "intent": "Quote the same order while SubscriptionController reports 333 USD of allowance left, so the waiver covers a third of it.", "next": "assert-subscription-partial" }, "assert-subscription-partial": { "action": "assert_json", "path": "{{params.output_dir}}/quote-subscription-partial.json", "assert": { "all": [ { "path": "$.isTestnet", "operator": "eq", "value": true }, { "path": "$.quote.feeSource", "operator": "eq", "value": "subscription" }, { "path": "$.quote.metamaskFeeDiscountBips", "operator": "eq", "value": 3330 }, { "path": "$.quote.undiscountedMetamaskFeeRate", "operator": "eq", "value": 0.001 }, { "path": "$.quote.metamaskFeeRate", "operator": "eq", "value": 0.00066 }, { "path": "$.checks.rateIsQuantizedDiscount", "operator": "eq", "value": true }, { "path": "$.allChecksPass", "operator": "eq", "value": true } ] }, "proves": [ "AC3", "AC5" ], "intent": "AC3 partial waiver: a 3330-bip discount whose blended 6.67 bips the venue charges as 6.6 bips (0.00066), the quantized undiscounted rate x (1 - d).", "next": "quote-twap" }, "quote-twap": { "action": "command", "timeout_ms": 240000, "intent": "Quote a 1000 USD ETH TWAP, which HyperLiquid places without a builder fee.", "next": "assert-twap" }, "assert-twap": { "action": "assert_json", "path": "{{params.output_dir}}/quote-twap.json", "assert": { "all": [ { "path": "$.isTestnet", "operator": "eq", "value": true }, { "path": "$.quote.chargesMetamaskBuilderFee", "operator": "eq", "value": false }, { "path": "$.quote.metamaskFeeRate", "operator": "eq", "value": 0 }, { "path": "$.checks.feeSourceAbsent", "operator": "eq", "value": true }, { "path": "$.checks.discountAbsent", "operator": "eq", "value": true }, { "path": "$.checks.undiscountedAbsent", "operator": "eq", "value": true } ] }, "proves": [ "AC4", "AC5" ], "intent": "AC4: a placement with no builder fee carries none of feeSource, metamaskFeeDiscountBips or undiscountedMetamaskFeeRate, while the default quote above carries them.", "next": "unit-tests" }, "unit-tests": { "action": "command", "cmd": "NODE_OPTIONS=--experimental-vm-modules yarn workspace @metamask/perps-controller exec jest packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts -t applyFeeResolution --reporters=default --verbose --no-coverage --no-watchman 2> \"{{params.output_dir}}/unit-tests.stderr.log\"", "timeout_ms": 300000, "intent": "Run the applyFeeResolution unit tests, which cover each source including rewards; Jest output goes to unit-tests.stderr.log.", "next": "assert-unit-exit" }, "assert-unit-exit": { "action": "assert_exit_code", "source": "unit-tests", "expected": 0, "intent": "Require the focused unit tests to pass.", "next": "assert-unit-default" }, "assert-unit-default": { "action": "assert_file", "path": "{{params.output_dir}}/unit-tests.stderr.log", "contains": "✓ reports a 0-bip discount and an undiscounted rate equal to the quoted rate when the default source won", "proves": [ "AC1", "AC5" ], "intent": "Default source: 0-bip discount and equal rates.", "next": "assert-unit-rewards" }, "assert-unit-rewards": { "action": "assert_file", "path": "{{params.output_dir}}/unit-tests.stderr.log", "contains": "✓ reports the rewards discount and the undiscounted rate it was taken off", "proves": [ "AC2", "AC5" ], "intent": "AC2: the rewards discount d and the floored undiscounted x (1 - d) rate.", "next": "assert-unit-subscription-full" }, "assert-unit-subscription-full": { "action": "assert_file", "path": "{{params.output_dir}}/unit-tests.stderr.log", "contains": "✓ reports the subscription discount at the rate a full waiver is charged", "proves": [ "AC3", "AC5" ], "intent": "Full subscription waiver at the charged rate.", "next": "assert-unit-subscription-partial" }, "assert-unit-subscription-partial": { "action": "assert_file", "path": "{{params.output_dir}}/unit-tests.stderr.log", "contains": "✓ reports the subscription discount at the rate a partial waiver is charged", "proves": [ "AC3", "AC5" ], "intent": "Partial subscription waiver at the charged rate.", "next": "changelog" }, "changelog": { "action": "command", "cmd": "awk '/^## \\[Unreleased\\]/{f=1;next} /^## \\[/{f=0} f && /metamaskFeeDiscountBips|undiscountedMetamaskFeeRate/' packages/perps-controller/CHANGELOG.md", "timeout_ms": 30000, "intent": "Print the perps-controller Unreleased changelog lines that name the new fields.", "next": "assert-changelog" }, "assert-changelog": { "action": "assert_output", "source": "changelog", "stream": "stdout", "match": "metamaskFeeDiscountBips[\\s\\S]*undiscountedMetamaskFeeRate", "proves": [ "AC6" ], "intent": "AC6: the new fields are announced under Unreleased.", "next": "index-evidence" }, "index-evidence": { "action": "index_artifacts", "artifacts": [ { "path": "{{params.output_dir}}/account.json", "type": "report", "category": "validation", "label": "dev3 HyperLiquid testnet account read" }, { "path": "{{params.output_dir}}/quote-default.json", "type": "report", "category": "validation", "label": "AC1 default-source quote" }, { "path": "{{params.output_dir}}/quote-subscription-full.json", "type": "report", "category": "validation", "label": "AC3 full-waiver quote" }, { "path": "{{params.output_dir}}/quote-subscription-partial.json", "type": "report", "category": "validation", "label": "AC3 partial-waiver quote" }, { "path": "{{params.output_dir}}/quote-twap.json", "type": "report", "category": "validation", "label": "AC4 TWAP quote" }, { "path": "{{params.output_dir}}/account.stderr.log", "type": "log", "category": "validation", "label": "dev3 account read controller log" }, { "path": "{{params.output_dir}}/quote-default.stderr.log", "type": "log", "category": "validation", "label": "AC1 quote controller log" }, { "path": "{{params.output_dir}}/quote-subscription-full.stderr.log", "type": "log", "category": "validation", "label": "AC3 full-waiver quote controller log" }, { "path": "{{params.output_dir}}/quote-subscription-partial.stderr.log", "type": "log", "category": "validation", "label": "AC3 partial-waiver quote controller log" }, { "path": "{{params.output_dir}}/quote-twap.stderr.log", "type": "log", "category": "validation", "label": "AC4 TWAP quote controller log" }, { "path": "{{params.output_dir}}/unit-tests.stderr.log", "type": "log", "category": "validation", "label": "Unit test output" } ], "intent": "Index the live quote evidence in the run package.", "next": "done" }, "done": { "action": "end", "status": "pass" } } } } ``` </details> ## **Validation Logs** <details><summary>Full output (25/25 passed, pass)</summary> ``` # MetaMask Recipe Run Status: pass Duration: 21s Nodes: 25/25 passed ## Steps - PASS select-account (metamask.wallet.list_accounts, 1.3s): count=1, proof=core-wallet-fixture-accounts - PASS assert-account (assert_output, 1ms): source=select-account, stream=stdout - PASS assert-account-name (assert_output, 1ms): source=select-account, stream=stdout - PASS assert-account-address (assert_output, 1ms): source=select-account, stream=stdout - PASS prepare-output (command, 25ms): exitCode=0 - PASS read-account (command, 3.6s): exitCode=0, stdout={"scenario":"account","accountName":"dev3","action":"metamask.perps.read_account","source":"perps-controller-standalone","network":"testnet","account":"0x719638ec3d32c8C87A521561D219C122bFCB3811","accountState":{"totalBalance":"0","spendableBalance":"0.0","withdrawableBalance":"0.0","marginUsed":"0.0","unrealizedPnl":"0","returnOnEquity":"0"},"proofPath":"perps-controller-getAccountState","funded":false} - PASS quote-default (command, 2.7s): exitCode=0, stdout={"scenario":"default","accountName":"dev3","account":"0x719638ec3d32c8C87A521561D219C122bFCB3811","network":"testnet","isTestnet":true,"params":{"orderType":"market","isMaker":false,"amount":"1000","symbol":"ETH"},"hostBenefits":null,"quote":{"feeRate":0.00145,"feeAmount":1.45,"protocolFeeRate":0.00045,"protocolFeeAmount":0.45,"metamaskFeeRate":0.001,"metamaskFeeAmount":1,"chargesMetamaskBuilderFee":true,"feeSource":"default","metamaskFeeDiscountBips":0,"undiscountedMetamaskFeeRate":0.001},"expected":{"feeSource":"default","metamaskFeeDiscountBips":0,"undiscountedMetamaskFeeRate":0.001,"metamaskFeeRate":0.001},"checks":{"feeSource":true,"discountBips":true,"undiscountedRate":true,"rateIsQuantizedDiscount":true,"rateIsCharged":true,"undiscountedEqualsRate":true},"allChecksPass":true,"proofPath":"mm-harness core adapter getCoreController -> PerpsController.calculateFees"} - PASS quote-subscription-full (command, 2.5s): exitCode=0, stdout={"scenario":"subscription-full","accountName":"dev3","account":"0x719638ec3d32c8C87A521561D219C122bFCB3811","network":"testnet","isTestnet":true,"params":{"orderType":"market","isMaker":false,"amount":"1000","symbol":"ETH"},"hostBenefits":{"eligible":true,"billingPeriodId":"tat-4059-proof","products":{"perps":{"builderFeeBips":"0","builderCode":null,"capMicroUsd":5000000000,"consumedMicroUsd":0,"remainingMicroUsd":5000000000,"exhausted":false}}},"quote":{"feeRate":0.00045,"feeAmount":0.45,"protocolFeeRate":0.00045,"protocolFeeAmount":0.45,"metamaskFeeRate":0,"metamaskFeeAmount":0,"chargesMetamaskBuilderFee":true,"feeSource":"subscription","metamaskFeeDiscountBips":10000,"undiscountedMetamaskFeeRate":0.001,"subscription":{"eligible":true,"reason":"eligible","remainingNotionalUsd":5000}},"expected":{"feeSource":"subscription","metamaskFeeDiscountBips":10000,"undiscountedMetamaskFeeRate":0.001,"metamaskFeeRate":0},"checks":{"feeSource":true,"discountBips":true,"undiscountedRate":true,"rateIsQuantizedDiscount":true,"rateIsCharged":true},"allChecksPass":true,"proofPath":"mm-harness core adapter getCoreController -> PerpsController.calculateFees"} - PASS quote-subscription-partial (command, 2.5s): exitCode=0, stdout={"scenario":"subscription-partial","accountName":"dev3","account":"0x719638ec3d32c8C87A521561D219C122bFCB3811","network":"testnet","isTestnet":true,"params":{"orderType":"market","isMaker":false,"amount":"1000","symbol":"ETH"},"hostBenefits":{"eligible":true,"billingPeriodId":"tat-4059-proof","products":{"perps":{"builderFeeBips":"0","builderCode":null,"capMicroUsd":5000000000,"consumedMicroUsd":4667000000,"remainingMicroUsd":333000000,"exhausted":false}}},"quote":{"feeRate":0.0011099999999999999,"feeAmount":1.1099999999999999,"protocolFeeRate":0.00045,"protocolFeeAmount":0.45,"metamaskFeeRate":0.00066,"metamaskFeeAmount":0.66,"chargesMetamaskBuilderFee":true,"feeSource":"subscription","metamaskFeeDiscountBips":3330,"undiscountedMetamaskFeeRate":0.001,"subscription":{"eligible":true,"reason":"eligible","remainingNotionalUsd":333}},"expected":{"feeSource":"subscription","metamaskFeeDiscountBips":3330,"undiscountedMetamaskFeeRate":0.001,"metamaskFeeRate":0.00066},"checks":{"feeSource":true,"discountBips":true,"undiscountedRate":true,"rateIsQuantizedDiscount":true,"rateIsCharged":true},"allChecksPass":true,"proofPath":"mm-harness core adapter getCoreController -> PerpsController.calculateFees"} - PASS quote-twap (command, 5.4s): exitCode=0, stdout={"scenario":"twap","accountName":"dev3","account":"0x719638ec3d32c8C87A521561D219C122bFCB3811","network":"testnet","isTestnet":true,"params":{"orderType":"twap","isMaker":false,"amount":"1000","symbol":"ETH"},"hostBenefits":null,"quote":{"feeRate":0.00045,"feeAmount":0.45,"protocolFeeRate":0.00045,"protocolFeeAmount":0.45,"metamaskFeeRate":0,"metamaskFeeAmount":0,"chargesMetamaskBuilderFee":false},"expected":{"feeSourcePresent":false,"metamaskFeeDiscountBipsPresent":false,"undiscountedMetamaskFeeRatePresent":false},"checks":{"feeSourceAbsent":true,"discountAbsent":true,"undiscountedAbsent":true,"noBuilderFee":true},"allChecksPass":true,"proofPath":"mm-harness core adapter getCoreController -> PerpsController.calculateFees"} - PASS unit-tests (command, 2.7s): exitCode=0 - PASS assert-unit-exit (assert_exit_code, 0ms): source=unit-tests, expected=0, actual=0 - PASS changelog (command, 17ms): exitCode=0, stdout=- 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 - `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 - PASS assert-changelog (assert_output, 2ms): source=changelog, stream=stdout, match=metamaskFeeDiscountBips[\s\S]*undiscountedMetamaskFeeRate - PASS index-evidence (index_artifacts, 57ms) - PASS done (end, 0ms) ``` </details> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them ## **Screenshots/Recordings** <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Additive optional API fields on fee quotes with no submit-path or pricing logic changes beyond populating metadata clients already consume via feeSource. > > **Overview** > Extends **`FeeCalculationResult`** (from `calculateFees` / fee preview re-pricing) with **`metamaskFeeDiscountBips`** and **`undiscountedMetamaskFeeRate`**, alongside the existing **`feeSource`**, so UIs can show strike-through original fees and discount badges without hard-coding the default builder fee or re-deriving discounts from **`metamaskFeeRate`**. > > **`applyFeeResolution`** now sets both fields from the same **`discountBips`** used to quantize **`metamaskFeeRate`**. The undiscounted rate is computed at zero discount (not read from stale provider state when **`default`** wins). **`metamaskFeeDiscountBips`** is **`0`** when no reduction applied; non-**`default`** **`feeSource`** does not imply a discount. All three attribution fields stay absent when the quote was not re-priced (e.g. no builder fee). Docs stress that **`metamaskFeeRate`** is already discounted and must not be discounted again. > > Unit tests cover **`default`**, **`rewards`** (including 0-bip tie), and **`subscription`** full/partial waivers; changelog updated under Unreleased. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e80e8b7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Naz-Ovh
pushed a commit
to 0x-fork/metamask-core
that referenced
this pull request
Oct 7, 2026
## Explanation Releases `@metamask/perps-controller` **19.0.0 → 20.0.0**. The monorepo version goes **1314.0.0 → 1315.0.0**. No other package is being published. Perps-controller has no in-monorepo dependents that need a workspace range bump. The bump is **major**. Public unions and Lighter order validation change: - **BREAKING:** Lighter `placeOrder` / `validateOrder` accept supported native attached TP/SL instead of refusing all attachments. Gate forwarding on `attachedTpsl`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** Grouped Lighter signer calls require grouping/count `1/2` or `2/2` with two orders, or `3/3` with three orders. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** `ScaleOrderChild.state` adds `canceled`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** `DirectProviderOrderCapabilitiesUnavailableReason` and `OrderCapabilitiesUnavailableReason` add `order_market_unsupported`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** Stray `triggerPrice` on Lighter basic market/limit orders is refused with `ORDER_TRIGGER_PRICE_NOT_SUPPORTED`. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** Lighter position TP/SL replacement and removal preserve independent partial triggers. ([MetaMask#10638](MetaMask#10638)) - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Treat missing as unknown, not zero. ([MetaMask#10605](MetaMask#10605)) Also ships Lighter Scale/Chase/TWAP probe work, fee quote attribution (`feeSource`, `metamaskFeeDiscountBips`), HyperLiquid agent/signing fixes, quieter optional-messenger failures, and watchlist hydration/write races. ## Changelog Moved Unreleased entries in `packages/perps-controller/CHANGELOG.md` under `[20.0.0]`. Removed Uncategorized monorepo release markers (1311–1314) that do not affect package consumers. Dropped a duplicate Chase probe summary already covered by more specific entries. ## References - Source PRs: [MetaMask#10638](MetaMask#10638), [MetaMask#10618](MetaMask#10618), [MetaMask#10605](MetaMask#10605), [MetaMask#10643](MetaMask#10643), [MetaMask#10650](MetaMask#10650), [MetaMask#10651](MetaMask#10651), [MetaMask#10665](MetaMask#10665), [MetaMask#10670](MetaMask#10670), [MetaMask#10683](MetaMask#10683) - No in-monorepo consumer packages to bump. Mobile and Extension exhaustive union matches need the new members on upgrade. Lighter attached TP/SL stays gated on `attachedTpsl` and client rollout. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
PerpsController.calculateFeesalready decides which fee source wins for a quote (default,rewardsorsubscription) insideRewardsIntegrationService.resolveFee, and uses that winner to re-price the MetaMask builder fee. The winner itself was dropped before the quote reached clients, so a client wanting to say why a fee is reduced (for example, a member badge for a subscription waiver versus a VIP badge for a rewards discount) had to guess from the rates.This PR adds an optional
feeSourcetoFeeCalculationResult, set inapplyFeeResolutionfrom the same resolution the rates were priced with.Why
applyFeeResolution. It is the only place that knows whether the resolution actually made it into the quote. Its early-return paths (no resolution, no MetaMask rate, or a placement that carries no builder fee, such as TWAP or Lighter) hand back the provider's quote untouched, sofeeSourceis absent there instead of naming a discount the quote does not contain. The label and the rates come from one resolution object and cannot disagree.Why
feeSourcerather thansource.FeeCalculationResultmixes protocol and MetaMask components, so a baresourceis ambiguous.TradingServicealready logs this value asfeeSource.Reliability notes (documented on the field):
subscriptionalways means a real reduction: the resolver only picks it when it is strictly cheaper after venue quantization.rewardswins ties with the default (<=), so it can carry a 0% discount. Consumers should checkmetamaskFeeRatebefore presenting it as a reduction. The value is kept identical toPerpsFeeResolution.sourcerather than normalized, so the preview, the submit-path logs and attribution all use the same vocabulary.References
N/A
Checklist
Note
Low Risk
Additive optional field on fee quote types with no change to fee math; consumers that ignore the new property behave as before.
Overview
Fee previews from
calculateFeescan now expose which fee source (default,rewards, orsubscription) the quoted MetaMask builder fee was priced from, so UIs can label discounts without inferring them from rates alone.An optional
feeSourceonFeeCalculationResultis set inapplyFeeResolutionfrom the same unified resolution used to re-pricemetamaskFeeRateand totals. When the quote is not re-priced (no resolution, no MetaMask rate, or placements with no builder fee),feeSourcestays absent. JSDoc and the changelog note thatrewardscan win with a 0% discount—clients should checkmetamaskFeeRatebefore showing a reduction—and that the field describes the preview only (submit resolves fees again).Tests assert
feeSourcefor subscription, rewards, and default winning paths.Reviewed by Cursor Bugbot for commit 59ca4b0. Bugbot is set up for automated code reviews on this repo. Configure here.