Skip to content

feat(perps-controller): report the winning fee source on fee quotes - #10650

Merged
tanguyenvn merged 5 commits into
mainfrom
feat/perps-fee-source-on-fee-quote
Oct 2, 2026
Merged

tanguyenvn merged 5 commits into
mainfrom
feat/perps-fee-source-on-fee-quote

Conversation

@tanguyenvn

@tanguyenvn tanguyenvn commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

PerpsController.calculateFees already decides which fee source wins for a quote (default, rewards or subscription) inside RewardsIntegrationService.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 feeSource to FeeCalculationResult, set in applyFeeResolution from 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, so feeSource is 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 feeSource rather than source. FeeCalculationResult mixes protocol and MetaMask components, so a bare source is ambiguous. TradingService already logs this value as feeSource.

Reliability notes (documented on the field):

  • subscription always means a real reduction: the resolver only picks it when it is strictly cheaper after venue quantization.
  • rewards wins ties with the default (<=), so it can carry a 0% discount. Consumers should check metamaskFeeRate before presenting it as a reduction. The value is kept identical to PerpsFeeResolution.source rather than normalized, so the preview, the submit-path logs and attribution all use the same vocabulary.
  • The value describes the preview only. The submit path resolves the fee again, so an order is attributed to whichever source wins at submission time, which matches how the quoted rates already behave.

References

N/A

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

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 calculateFees can now expose which fee source (default, rewards, or subscription) the quoted MetaMask builder fee was priced from, so UIs can label discounts without inferring them from rates alone.

An optional feeSource on FeeCalculationResult is set in applyFeeResolution from the same unified resolution used to re-price metamaskFeeRate and totals. When the quote is not re-priced (no resolution, no MetaMask rate, or placements with no builder fee), feeSource stays absent. JSDoc and the changelog note that rewards can win with a 0% discount—clients should check metamaskFeeRate before showing a reduction—and that the field describes the preview only (submit resolves fees again).

Tests assert feeSource for 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.

@tanguyenvn
tanguyenvn requested review from a team as code owners October 1, 2026 09:36
@tanguyenvn

Copy link
Copy Markdown
Contributor Author

@metamaskbot publish-previews

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Preview builds have been published. Learn how to use preview builds in other projects.

Expand for full list of packages and versions.
@metamask-previews/account-tree-controller@11.0.0-preview-378f5fcf7
@metamask-previews/accounts-controller@40.0.0-preview-378f5fcf7
@metamask-previews/address-book-controller@8.0.0-preview-378f5fcf7
@metamask-previews/advanced-chart-core@1.0.0-preview-378f5fcf7
@metamask-previews/ai-controllers@2.0.0-preview-378f5fcf7
@metamask-previews/analytics-controller@3.2.0-preview-378f5fcf7
@metamask-previews/analytics-data-regulation-controller@0.0.0-preview-378f5fcf7
@metamask-previews/announcement-controller@9.0.0-preview-378f5fcf7
@metamask-previews/app-metadata-controller@3.0.0-preview-378f5fcf7
@metamask-previews/approval-controller@10.0.0-preview-378f5fcf7
@metamask-previews/assets-controller@17.0.0-preview-378f5fcf7
@metamask-previews/assets-controllers@112.0.4-preview-378f5fcf7
@metamask-previews/authenticated-user-storage@4.1.0-preview-378f5fcf7
@metamask-previews/base-controller@10.0.0-preview-378f5fcf7
@metamask-previews/base-data-service@2.1.0-preview-378f5fcf7
@metamask-previews/bitcoin-regtest-up@2.0.0-preview-378f5fcf7
@metamask-previews/bridge-controller@81.4.0-preview-378f5fcf7
@metamask-previews/bridge-status-controller@76.3.3-preview-378f5fcf7
@metamask-previews/build-utils@4.0.0-preview-378f5fcf7
@metamask-previews/chain-agnostic-permission@2.0.0-preview-378f5fcf7
@metamask-previews/chomp-api-service@6.0.0-preview-378f5fcf7
@metamask-previews/claims-controller@1.0.2-preview-378f5fcf7
@metamask-previews/client-controller@2.0.0-preview-378f5fcf7
@metamask-previews/client-utils@3.0.3-preview-378f5fcf7
@metamask-previews/compliance-controller@3.0.0-preview-378f5fcf7
@metamask-previews/composable-controller@13.0.0-preview-378f5fcf7
@metamask-previews/config-registry-controller@4.0.0-preview-378f5fcf7
@metamask-previews/connectivity-controller@1.0.0-preview-378f5fcf7
@metamask-previews/controller-utils@13.0.0-preview-378f5fcf7
@metamask-previews/core-backend@11.0.0-preview-378f5fcf7
@metamask-previews/cryptography@0.0.0-preview-378f5fcf7
@metamask-previews/delegation-controller@4.0.0-preview-378f5fcf7
@metamask-previews/earn-controller@13.0.2-preview-378f5fcf7
@metamask-previews/eip-5792-middleware@4.0.1-preview-378f5fcf7
@metamask-previews/eip-7702-internal-rpc-middleware@1.0.0-preview-378f5fcf7
@metamask-previews/eip1193-permission-middleware@3.0.0-preview-378f5fcf7
@metamask-previews/eth-block-tracker@16.0.0-preview-378f5fcf7
@metamask-previews/eth-json-rpc-middleware@25.0.0-preview-378f5fcf7
@metamask-previews/eth-json-rpc-provider@7.0.0-preview-378f5fcf7
@metamask-previews/foundryup@2.0.0-preview-378f5fcf7
@metamask-previews/gas-fee-controller@27.0.0-preview-378f5fcf7
@metamask-previews/gator-permissions-controller@6.0.1-preview-378f5fcf7
@metamask-previews/geolocation-controller@2.0.0-preview-378f5fcf7
@metamask-previews/java-tron-up@2.0.0-preview-378f5fcf7
@metamask-previews/json-rpc-engine@11.0.0-preview-378f5fcf7
@metamask-previews/json-rpc-middleware-stream@9.0.0-preview-378f5fcf7
@metamask-previews/keyring-controller@28.1.0-preview-378f5fcf7
@metamask-previews/kyc-controller@0.6.0-preview-378f5fcf7
@metamask-previews/local-node-utils@2.0.0-preview-378f5fcf7
@metamask-previews/logging-controller@10.0.0-preview-378f5fcf7
@metamask-previews/message-manager@15.0.0-preview-378f5fcf7
@metamask-previews/messenger@3.0.0-preview-378f5fcf7
@metamask-previews/messenger-cli@1.0.0-preview-378f5fcf7
@metamask-previews/money-account-api-data-service@2.1.0-preview-378f5fcf7
@metamask-previews/money-account-balance-service@3.1.1-preview-378f5fcf7
@metamask-previews/money-account-controller@2.0.0-preview-378f5fcf7
@metamask-previews/money-account-upgrade-controller@5.1.0-preview-378f5fcf7
@metamask-previews/money-account-utils@2.1.0-preview-378f5fcf7
@metamask-previews/multichain-account-service@14.1.0-preview-378f5fcf7
@metamask-previews/multichain-api-middleware@5.0.0-preview-378f5fcf7
@metamask-previews/multichain-network-controller@4.0.0-preview-378f5fcf7
@metamask-previews/multichain-transactions-controller@8.0.0-preview-378f5fcf7
@metamask-previews/name-controller@10.0.0-preview-378f5fcf7
@metamask-previews/network-connection-banner-controller@1.0.0-preview-378f5fcf7
@metamask-previews/network-controller@37.0.0-preview-378f5fcf7
@metamask-previews/network-enablement-controller@7.0.1-preview-378f5fcf7
@metamask-previews/notification-services-controller@29.0.2-preview-378f5fcf7
@metamask-previews/passkey-controller@4.1.0-preview-378f5fcf7
@metamask-previews/permission-controller@14.0.0-preview-378f5fcf7
@metamask-previews/permission-log-controller@6.0.0-preview-378f5fcf7
@metamask-previews/perps-controller@18.0.1-preview-378f5fcf7
@metamask-previews/phishing-controller@18.1.1-preview-378f5fcf7
@metamask-previews/platform-api-docs@0.2.1-preview-378f5fcf7
@metamask-previews/polling-controller@17.0.0-preview-378f5fcf7
@metamask-previews/preferences-controller@24.0.0-preview-378f5fcf7
@metamask-previews/profile-controller@0.0.0-preview-378f5fcf7
@metamask-previews/profile-metrics-controller@5.1.2-preview-378f5fcf7
@metamask-previews/profile-sync-controller@33.0.0-preview-378f5fcf7
@metamask-previews/ramps-controller@26.1.0-preview-378f5fcf7
@metamask-previews/rate-limit-controller@8.0.0-preview-378f5fcf7
@metamask-previews/react-data-query@2.0.0-preview-378f5fcf7
@metamask-previews/remote-feature-flag-controller@7.0.0-preview-378f5fcf7
@metamask-previews/sample-controllers@6.0.0-preview-378f5fcf7
@metamask-previews/seedless-onboarding-controller@11.0.1-preview-378f5fcf7
@metamask-previews/selected-network-controller@27.0.0-preview-378f5fcf7
@metamask-previews/sentinel-api-service@2.0.0-preview-378f5fcf7
@metamask-previews/shield-controller@7.0.3-preview-378f5fcf7
@metamask-previews/signature-controller@40.0.0-preview-378f5fcf7
@metamask-previews/smart-transactions-controller@27.0.3-preview-378f5fcf7
@metamask-previews/snap-account-service@4.0.0-preview-378f5fcf7
@metamask-previews/social-controllers@3.4.0-preview-378f5fcf7
@metamask-previews/solana-test-validator-up@2.0.0-preview-378f5fcf7
@metamask-previews/stellar-quickstart-up@0.0.0-preview-378f5fcf7
@metamask-previews/storage-service@2.0.0-preview-378f5fcf7
@metamask-previews/subscription-controller@11.0.0-preview-378f5fcf7
@metamask-previews/transaction-controller@72.0.1-preview-378f5fcf7
@metamask-previews/transaction-pay-controller@30.0.0-preview-378f5fcf7
@metamask-previews/user-operation-controller@42.0.1-preview-378f5fcf7
@metamask-previews/utils@12.0.0-preview-378f5fcf7
@metamask-previews/wallet@15.1.0-preview-378f5fcf7
@metamask-previews/wallet-cli@0.0.0-preview-378f5fcf7

geositta
geositta previously approved these changes Oct 1, 2026

@geositta geositta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to merge. Thanks for documenting that a rewards source can represent no reduction, and submission resolves the fee again.

# Conflicts:
#	packages/perps-controller/CHANGELOG.md
@tanguyenvn
tanguyenvn added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 8a4975a Oct 2, 2026
45 checks passed
@tanguyenvn
tanguyenvn deleted the feat/perps-fee-source-on-fee-quote branch October 2, 2026 20:54
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>
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