Skip to content

feat(perps): report the applied MetaMask fee discount on perps fee quotes - #10683

Merged
abretonc7s merged 4 commits into
mainfrom
TAT-4059-feat-add-perps-fee-discount-reporti
Oct 6, 2026
Merged

abretonc7s merged 4 commits into
mainfrom
TAT-4059-feat-add-perps-fee-discount-reporti

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

PerpsController.calculateFees re-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 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

Validation 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)
# 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)

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

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 (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.

Reviewed by Cursor Bugbot for commit e80e8b7. Bugbot is set up for automated code reviews on this repo. Configure here.

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 abretonc7s changed the title chore: prepare farmslot publication pkg-7d25403b-muvx96rk feat(perps): report the applied MetaMask fee discount on perps fee quotes Oct 6, 2026
@abretonc7s
abretonc7s marked this pull request as ready for review October 6, 2026 00:10
@abretonc7s
abretonc7s requested review from a team as code owners October 6, 2026 00:10
@abretonc7s
abretonc7s deployed to default-branch October 6, 2026 00:10 — with GitHub Actions Active
@abretonc7s
abretonc7s enabled auto-merge October 6, 2026 00:12
@abretonc7s
abretonc7s added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit c859513 Oct 6, 2026
45 checks passed
@abretonc7s
abretonc7s deleted the TAT-4059-feat-add-perps-fee-discount-reporti branch October 6, 2026 01:30
@abretonc7s abretonc7s mentioned this pull request Oct 6, 2026
4 tasks done
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants