Repository navigation
feat(perps): report the applied MetaMask fee discount on perps fee quotes - #10683
Merged
Merged
Conversation
Add optional `metamaskFeeDiscountBips` and `undiscountedMetamaskFeeRate` to `FeeCalculationResult`, set in `applyFeeResolution` next to `feeSource` from the same discount that prices `metamaskFeeRate`. Clients can show the original fee and the discount without the builder fee constant. Both are absent whenever `feeSource` is.
`metamaskFeeDiscountBips` is 0 both when `default` wins and when `rewards` wins with a 0% discount, so a non-default `feeSource` does not imply a discount.
State that `metamaskFeeDiscountBips` is 0 whenever no reduction applied, including a rewards win at 0%, so a non-default `feeSource` is not read as a discount.
abretonc7s
marked this pull request as ready for review
October 6, 2026 00:10
abretonc7s
enabled auto-merge
October 6, 2026 00:12
geositta
approved these changes
Oct 6, 2026
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.calculateFeesre-prices the MetaMask fee from the unified fee resolution and, since #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 frommetamaskFeeRateand the builder-fee constant, which is what Mobile does today.This adds two optional fields to
FeeCalculationResult, set inapplyFeeResolutionnext tofeeSource:metamaskFeeDiscountBips: the discount the winning source applied, in basis points off the default builder fee.0when no reduction applied: whendefaultwon, or whenrewardswon with a 0% discount. AfeeSourceother thandefaultdoesn't imply a discount; check this value.undiscountedMetamaskFeeRate: the MetaMask fee rate before that discount.Both come from the same
discountBipsthat pricesmetamaskFeeRate, so they can't disagree with it.metamaskFeeRateequals 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 equalsmetamaskFeeRatewheneverdefaultwins. The fields follow thefeeSourcerule: absent when the quote wasn't re-priced, for example a TWAP, which carries no builder fee. The docs saymetamaskFeeRateis already discounted and must not be discounted again.Proof, on HyperLiquid testnet for fixture account dev3, reading
calculateFeesfrom thePerpsControllerthat the mm-harness core adapter builds from this branch (1000 USD ETH order):feeSourcemetamaskFeeDiscountBipsundiscountedMetamaskFeeRatemetamaskFeeRatedefaultsubscriptionsubscriptionSubscription benefits were supplied through
SubscriptionController:getBenefits, as a client wires them. The same recipe fails onmain. 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 multiplymetamaskFeeRateby a locally fetched discount. Since Core already applies it, they should read these fields and drop the local multiplier once this is released.References
feeSource); ships in the same perps-controller releaseValidation Recipe
recipe.json (0 steps — Perps fee quote reports the applied MetaMask fee discount)
{ "$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 (#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" } } } }Validation Logs
Full output (25/25 passed, pass)
Checklist
Screenshots/Recordings
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(fromcalculateFees/ fee preview re-pricing) withmetamaskFeeDiscountBipsandundiscountedMetamaskFeeRate, alongside the existingfeeSource, so UIs can show strike-through original fees and discount badges without hard-coding the default builder fee or re-deriving discounts frommetamaskFeeRate.applyFeeResolutionnow sets both fields from the samediscountBipsused to quantizemetamaskFeeRate. The undiscounted rate is computed at zero discount (not read from stale provider state whendefaultwins).metamaskFeeDiscountBipsis0when no reduction applied; non-defaultfeeSourcedoes not imply a discount. All three attribution fields stay absent when the quote was not re-priced (e.g. no builder fee). Docs stress thatmetamaskFeeRateis already discounted and must not be discounted again.Unit tests cover
default,rewards(including 0-bip tie), andsubscriptionfull/partial waivers; changelog updated under Unreleased.Reviewed by Cursor Bugbot for commit e80e8b7. Bugbot is set up for automated code reviews on this repo. Configure here.