feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver - #10294
Conversation
…loid marking ADR 0064 has moved past the revision TAT-3618 was built against and rejects the dedicated approved-builder approach it shipped, citing per-user approval overhead and no order context. Rework the perps-controller side to match. Resolve the subscription source as a blended rate rather than a flat zero: 0 bips when the remaining allowance covers the order notional, otherwise MaxFee * (1 - remaining / orderNotional). That rate now competes in the existing lowest-wins comparison instead of short-circuiting it, so a partial blend can lose to a deeper VIP or season discount. The formula lives in one pure helper that preview and submit both call, and calculateFees threads the order notional through it, so a quoted fee and a charged fee cannot drift. Move subscription attribution from the builder address to the order's client order ID. Every source now pays through the standard builder at the resolved fee, which is also what lets a partial waiver charge a real blended fee. One provider helper stamps the program marker and a fee_reduction_applied flag when subscription wins, and every submission path routes through it: placement, Scale ladder, attached and standalone TP/SL, position TP/SL update, batch close, modify/replace, and chase. Any other source leaves the id untouched. The flag byte sits after the leading marker rather than replacing it, so a Scale rung keeps its group marker and rung index and stays recoverable. The Scale identity generator now reserves that byte; without it, random entropy would set the flag on roughly half of all unmarked ladders. Add SubscriptionController allowed actions for benefits hydration and CAIP-10 trading-address registration at preview time, re-sent after an account switch, falling back to the injected dependency when a client registers neither. Add the perpsSubscriptionFeeWaiverEnabled remote flag, which kills only the subscription source and fails open. Deprecate the dedicated subscription builder rather than deleting it: the acceptance criterion conditions removal on shadow-mode verification, which has not happened, so the approval path is made unreachable from order construction and kept for a cheap rollback. The cloid program marker is a placeholder; the registry value is an open TODO in the ADR and is owned by the cloid schema owners. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two blockers the original test suite did not cover. The submit path resolved the fee with no order notional, so every bounded allowance took the resolver's "no notional to blend against" branch and came back as a full waiver. A 250 USD allowance against a 1000 USD order was quoted 7.5 bips by calculateFees and charged 0 at submit, over-consuming the allowance and stamping the cloid as fully waived. Thread the notional through #calculateFeeDiscountWithMeasurement to all six submit paths, priced from the parameters already in scope: usdAmount where the hybrid model supplies it, otherwise size times the best available price. A batch close is priced from the sum of the positions it will close, since HyperLiquid takes one builder context for the whole batch. An order that cannot be priced passes undefined rather than a guess, which is the behaviour it had before. registerTradingAddress early-returned on the injected subscription dependency, gating the messenger call on the very callback it was written to replace, so a client shipping SubscriptionController without the dependency registered nothing. Drop the guard and let the existing catch absorb an unregistered action. Writing the test surfaced a second defect: a messenger that answers an unregistered action with undefined would have cached the address as registered, so a SubscriptionController wired after the first preview would never receive it. An unhandled call no longer touches the dedupe cache. Also rename the preview test to what it actually checks and add the submit-side assertion it claimed, export the new util from the barrel, and populate the recipe-quality dimensions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects in the cloid marking introduced by this branch. A caller-supplied OrderParams.clientOrderId was rewritten in place when the subscription source won, so the venue received an id the caller never chose: 0xdeadbeefcafebabe0011223344556677 was submitted as 0xdeadbeef01febabe0011223344556677, and a short id was discarded outright. That id is the caller's reconciliation and idempotency key, which is a correctness contract, while attribution is observability — so only ids this package generates are re-stamped now, and anything else is returned untouched and goes unattributed. A nearly-spent allowance blends to just under the full fee, so it still won the lowest-wins comparison while its discount rounded to zero and the builder fee floored to the full rate. The order was charged full price and stamped fee_reduction_applied. Marking now follows the charged fee rather than the winning source. hasFeeReductionAppliedFlag read the flag byte with no marker check. The byte held random group entropy in Scale ladders placed before this change, so 51% of 2000 synthetic historical rungs decoded as fee-waived. The subscription program marker is the one prefix no released client ever emitted, so the flag is only trusted behind it; measured 0% after. The consequence is that a marked Scale rung now reads as unwaived, since it keeps its own group marker to preserve recovery and cancel-by-cloid — recorded in the exported JSDoc and the changelog, and asserted in the Scale tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects, two of them in paths this branch claimed to have already covered. Benefits hydration was unreachable over the messenger. Both the eligibility read and the refresh returned early on the injected subscription dependency, before SubscriptionController:getPerpsBenefits could run, so a client that adopts the controller action without retaining the legacy callback always resolved no-source. The previous round removed that guard from address registration but left it on hydration, which is the path the ADR actually targets. A source predicate now accepts either wiring, and the refresh attempts the messenger regardless so a delegated registration — invisible to getRegisteredActionTypes — can prove itself on first call. A full position close priced its fee from the close parameters, which commonly carry only a symbol, so the notional was undefined and the resolver quoted a full waiver on an order the preview had blended. The authoritative position is loaded a few lines earlier; it now supplies the notional, and a partial close is priced from the position's value per unit. Provenance of a client order ID was inferred from its leading marker, so a caller-supplied cloid beginning with a reserved prefix had its flag byte rewritten — the exact contract the previous round introduced. A prefix cannot prove authorship, so the marking now takes an explicit isGenerated flag and the provider declares the ids it just generated. Also corrects the evidence: the coverage table marked submit agreement and the SubscriptionController integration proven without exercising the failing paths, and claimed more attribution than Scale ladders can deliver downstream. Four assertions added, the two deliberate marking exclusions documented, and the weak count corrected from 0 to 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adopts the registered subscription program_id 0x0100, zero-extended big-endian into the existing 4-byte marker field so the flag-byte offset and the rest of the cloid layout are unchanged. It cannot collide with the Scale marker and no released client emitted a cloid starting with those bytes, so the decoder stays safe against historical fills. A chase replacement paid the discounted fee with an unmarked client order ID. The session already stores the builder fee it was quoted at, precisely because the fee resolution behind it is cleared when the caller's placeOrder returns — but marking still read that live resolution, so every replacement after the first went out unattributed. The marking decision is now captured on the session alongside the fee. Proven by a test that failed before the fix. A bounded allowance with no determinable order notional resolved to a full waiver, charging nothing on an order of unknown size and over-consuming the cap. It now withholds the source, matching how an exhausted or stale gate behaves. An unbounded allowance is unchanged: with no reported cap there is nothing to over-consume. Batch-close notional summed every aggregated provider's positions while the batch routes to one, inflating the notional and shrinking the waiver. Position carries no provider id, so it is now read through the provider that submits. The deprecated approval method resolves true rather than false: it answers whether the subscription builder is ready, and nothing needs approving, so false read as a setup failure. Adds an exact ./utils subpath export and exports the two SubscriptionController action types. The allowed-actions unions are deliberately not exported — the controller guidelines forbid it and lint enforces it. Two further findings ask that orders which cannot carry attribution — Scale fills and caller-supplied client order IDs — be denied the subscription rate. That means charging entitled subscribers full price to keep backend accounting clean, which is a product trade-off rather than an implementation detail; it is recorded in the task report with the structural notes a decision would need. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects, three of them in fixes from earlier rounds. A rejected messenger benefits read could erase a valid cached snapshot. The catch claimed to fall back rather than erase, but with no injected subscription dependency it fell through to null, which the refresh then stored as a successful "no subscription" result — wiping a waiver the user still held. The rejection now propagates when nothing else can answer, so the outer handler keeps the previous snapshot. Closes priced against positions their write could not reach. The previous round routed batch-close pricing through provider.getPositions() on the assumption that it read only the submitting provider; in aggregated mode it spans every active provider while the write goes to the default one. A routed single close had the same shape through symbol-only matching, so with two providers listing one market it could price the wrong provider's position. AggregatedPerpsProvider now reports which provider a write reaches — protocolId names the aggregate and reads span providers, so nothing exposed this — and pricing filters on the providerId the aggregator already injects. Preview quoted an unfloored fractional fee while submit floored to the venue's tenths of a basis point, so a 6.667-bip blend was quoted at 6.667 and charged at 6.6. Both paths now share one quantization helper. The migration note promised that omitting the notional preserves a full waiver, which the previous round reversed for bounded allowances. Corrected, and the breaking note now also covers rewards repricing and the quantization change. Also corrects the evidence rather than the criterion: AC4 requires every winning placement to mark the cloid, and caller-supplied client order IDs and Scale rungs do not, so it is recorded as PARTIAL with the recipe-quality verdict moved to warn. The underlying attribution gap needs a product decision and stays open in the task report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consumes the real SubscriptionController instead of an invented one. This branch declared SubscriptionController:getPerpsBenefits and SubscriptionController:registerAddress as structural types, on the stated premise that SubscriptionController does not live in this monorepo. It does: packages/subscription-controller exposes SubscriptionController:getBenefits, and neither of the two names this branch used exists anywhere. The real contract also differs — allowances arrive in micro-USD, eligibility is a response flag rather than a status string — so the shape being decoded was wrong too. perps-controller now depends on @metamask/subscription-controller, imports its action type rather than restating it, and converts micro-USD to USD once at the boundary so the gate and the blended-rate formula keep working in whole USD. The registerAddress action is removed; address registration runs through an optional hook on the injected dependency until a real action exists, rather than calling a name nothing answers. Four smaller defects: a fee preview returned the provider's own rate when the default source won, which reflects whatever discount the last submit pushed into it and could leak a concurrent order's discount into an unrelated quote; a take-profit/stop-loss update with neither a position snapshot nor tracking data resolved no notional and, since bounded waivers now fail closed, silently lost the waiver; a synchronous throw from a registered benefits handler was indistinguishable from an unregistered action and fell through to null, erasing a cached snapshot; and an account switch cleared registration without registering the new address, so an order submitted before the next preview went unattributed. Also corrects public type documentation that claimed quoted rates are not adjusted from the subscription waiver, and a coverage document that recorded AC4 as PARTIAL and then counted it as proven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reclassifies the messenger-union expansion as breaking. Messenger constrains a child's action union to be a subset of its parent's, so adding SubscriptionController:getBenefits to PerpsControllerAllowedActions forces every strict parent messenger type to add the action before it builds — including clients that never register the handler. Runtime behaviour for those clients is unchanged because the injected fallback still applies, but the build is not, and the changelog described this as additive. It is now a breaking entry with migration guidance to coordinate the client messenger updates. The messenger action docblock for approveSubscriptionBuilderFee still described the pre-ADR contract, promising that waivers fall back to the ordinary builder until approval succeeds. The controller method was deprecated and made a no-op two rounds ago; this second docblock was missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects, three of them introduced by earlier rounds of this branch. A UserNotSubscribed rejection was treated as a failed read and preserved the cached snapshot. It is a definitive answer — SubscriptionController throws it when entitlement has ended and clears its own benefits state on the same path — so preserving the snapshot kept granting the waiver for the rest of the ten-minute staleness window after the user stopped paying. It now resolves to null, which replaces the snapshot, while every other failure still preserves it. An order edit marked its replacement client order ID as fee-reduced, two lines below a comment recording that HyperLiquid's modify action carries no builder field. No MetaMask fee is charged on that action, so the marking reported a reduction on an order that paid nothing. The replacement now inherits the resting order's attribution instead. A trigger placement could not be priced: the notional resolver consulted the limit price, the caller's snapshot and the live quote, but not triggerPrice, which is the only price a stop or take-profit placement carries. Bounded allowances fail closed, so such an order silently lost the waiver. A fee preview read the subscription status separately from the resolution that produces its rates, so an invalidation or feature-flag change between the two could attach metadata describing a waiver the rates did not reflect. Also regenerates PerpsController-method-action-types.ts, which a previous commit hand-edited even though it is generated, leaving messenger-action-types:check failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reports the trading-address registration gap instead of returning silently. A client that adopts SubscriptionController over the messenger but injects no registerTradingAddress hook cannot register an address at all — no such messenger action exists — so its fills arrive unattributed with nothing to point at. Calling an action nothing answers would be worse, so the early return stays, but it now logs and the JSDoc states the consequence rather than only explaining the design. Removes a stale paragraph above SUBSCRIPTION_CLOID_CONFIG that still called the program id a placeholder pending the registry, directly above the docblock describing the registered value and its encoding. Narrows the recipe decision's AC5 claim, which asserted that address registration runs via the SubscriptionController integration — the precise thing AC5 is recorded PARTIAL for not doing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects the resolveFee documentation, which still described the fail-open behaviour a previous round replaced. It said a caller with no order notional receives the full-waiver rate; that holds only when the backend reported no allowance bound. A bounded allowance is withheld in that case, deliberately, so an order of unknown size cannot silently spend the cap. The docblock now distinguishes the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects in code paths the suite did not exercise.
products.perps is always present on the benefits response, so testing its
existence proved nothing about entitlement: a profile eligible for other
products, whose perps block carried no builder fee, no allowance and no cap, was
granted an unbounded full waiver. Entitlement now requires positive evidence
from the perps block itself.
The cloid decoder validated length and prefix but not that the id was hex.
parseInt('1z', 16) is 1, so a client order ID with a partly-hex flag byte
reported whichever flags its leading digit encoded. The whole id is now matched
against a hex pattern.
applyFeeResolution accepted any finite amount, so a negative notional produced
negative feeAmount and metamaskFeeAmount. A non-positive value is not an order
size; rates are still re-priced but the amounts are left as the provider
reported them.
A synchronous benefits-handler failure on the very first call was
indistinguishable from an unregistered action, because the distinction rested on
whether a call had previously succeeded, and its null was cached as a successful
"no subscription" answer. The distinction is now made on the error itself.
Also repairs a docblock left mangled by an earlier insertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Report source: 'subscription' only when the waiver survives the venue's quantization of the builder fee to tenths of a basis point. A blend just under the default rounds to the same charge, so such an order was labelled subscription-sourced with a 0 bips discount while paying full price, and disagreed with the client order ID, which already withholds its marking there. Distinguish a metamaskFeeRate of 0 that means "this placement carries no builder fee" from the 0 a concurrent fully waived submit leaves in provider state, via a new chargesMetamaskBuilderFee field on FeeCalculationResult. An ordinary preview racing such a submit previously inherited its waiver. Reject non-hex values from isSubscriptionProgramCloid, which matched on length and prefix alone although it gates the decoder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Make repricing a zero MetaMask fee rate opt-in. The previous commit added chargesMetamaskBuilderFee so a structural zero could be told apart from a waived one, but the call site mapped both false and undefined onto "charges a fee". A PerpsProvider written before the field existed reports a zero and no policy, so its quote gained the default 10-bip fee on an order that pays none. The policy is now carried as a tri-state, and only a provider that explicitly reports it does charge a builder fee has its zero overwritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fee-resolver-adr-0064 # Conflicts: # packages/perps-controller/CHANGELOG.md
Repair three JSDoc blocks garbled by a patch applied twice over itself: - applyFeeResolution: drop the duplicated summary line whose stray '/**' rendered as the literal 'resolution./**' in TypeDoc output. - #resolvePositionUnitPrice: same shape, drop the duplicated line. - #calculateFeeDiscountWithMeasurement: remove the stranded block left documenting #resolveBatchCloseNotionalUsd and reattach a corrected one, documenting orderNotionalUsd — the parameter whose absence resolves every bounded allowance as a full waiver. Comment-only; no executable statement changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- tsconfig.lint.json: add the missing `../subscription-controller/tsconfig.lint.json` reference, via `yarn lint:tsconfigs:fix` (lint:tsconfigs:all). - README.md: add the `perps_controller --> subscription_controller` edge to the dependency graph, via `yarn readme-content:update` (readme-content:check). - subscriptionFeeWaiver.ts: apply oxfmt to `isSubscriptionProgramCloid` (lint:misc:check). - CHANGELOG.md: record that the trading-address CAIP-10 is built from the wallet's selected network rather than HyperLiquid's chain, so a client on another network registers under the wrong chain and its fills cannot be attributed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Check changelog` requires each Unreleased entry to link the pull request that introduced it, which the released sections already do. All 35 top-level entries now carry the #10294 link; nested detail bullets are left unlinked, matching the surrounding convention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fee-resolver-adr-0064
Register the trading address under HyperLiquid's own chain (eip155:999 / eip155:998) instead of the wallet's selected network, so a fill decoded off the HyperLiquid fan-out can match the registered identifier. PerpsController supplies isTestnet at both call sites. Price a partial TP/SL update from the trigger size it submits rather than the whole position, taking the larger of takeProfitSize and stopLossSize since both go up under one builder context. An unpriceable partial still falls back to the position notional. Honor closeAll ahead of symbols when pricing a batch close, matching the provider's own selection precedence. Discard a trading-address registration whose profile was invalidated while it was in flight, reusing the benefits-cache epoch fence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restore Prettier formatting in TradingService, which failed lint:misc:check on the previous push. Price a Scale ladder from the midpoint of scaleMinPrice and scaleMaxPrice. A Scale placement states no single price, so its notional could not be derived and a bounded subscription waiver was withheld at submit after a preview that quoted one. A stated usdAmount still takes precedence. Chase and TWAP state no price at all and remain priced from usdAmount or the quoted market price. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 99f8a1c. Configure here.
Bigshmow
left a comment
There was a problem hiding this comment.
One thing worth reconciling before this lands: ADR 0064 says "Resolver cache TTL stays ≤ 5 min because it is additive to every window above", but SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs is 10 min. Pre-existing on main rather than introduced here, but since this is the PR that implements 0064 it seems like the place to settle it — either bring the ceiling to 5, or update the ADR's blind-window math, which assumes 5 in the "trip threshold + cache TTL ≈ 20 min" figure.
Otherwise just one minor inline. Comparing sources after venue quantization is a nice catch, and withholding on a bounded allowance with unknown notional is the right fail-closed direction.
…fee-resolver-adr-0064 # Conflicts: # packages/perps-controller/CHANGELOG.md
… fee A fee tie let the subscription waiver claim the order, which marks the client order ID and spends the remaining allowance without making the order any cheaper than the source it tied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Distribute the generated Perps static review checklist for Mobile, Extension and Core. - This PR contains only Perps review content, its generator, parity tests and documentation. - It is independent of installer PR #174. The skill can be invoked directly from the checked out source; installer explicit-only controls are a separate follow-up in #174. - `--analyzer-out <path> --client <client>` optionally exports inlined context for an existing PR risk analyzer. It does not enable an analyzer integration. - Validation: the repository test suite passes, including per-client parity and install/reinstall checks. Farmslot validation ran the Core template with Astra medium, registered all 30 child checklist rows, saved three actionable findings plus line comments, and posted the selected review to MetaMask/core#10294 after the human gate.
## Explanation Publish `@metamask/perps-controller@18.0.0` so clients can consume the ADR-0064 cloid-based subscription fee waiver rework that has accumulated on `main` since 17.4.0 ([MetaMask#10294](MetaMask#10294)), along with the `fillId` addition to `OrderFill` ([MetaMask#10384](MetaMask#10384)). Major bump: the release carries two breaking changes, both from [MetaMask#10294](MetaMask#10294). 1. **`PerpsControllerAllowedActions` now includes `SubscriptionController:getBenefits` and `SubscriptionController:registerAddress`.** This is a type break for every client, including clients that never register those handlers. `Messenger` requires each child action to exist in the parent action union, so a strict parent messenger type must include both action types before it will build. Runtime behavior is unchanged for clients that don't register them — an unregistered action falls back to the injected `subscription` dependency — but the build does not pass without the type. **The Mobile and Extension messenger updates need to be coordinated with this release.** 2. **`PerpsController.calculateFees` now quotes the subscription waiver as a blended rate derived from the order notional.** `feeRate`, `feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from 17.4.0 when a subscription waiver applies, and `FeeCalculationParams.amount` (order notional in USD) is now required for quote/submit parity. Omitting it changes the quote rather than preserving the old one: a waiver with a bounded remaining allowance is withheld entirely from a notional-less quote, so the preview reports the next-lowest fee source. The release also deprecates `PerpsController.approveSubscriptionBuilderFee`, `PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated subscription builder address configuration — subscription attribution now rides on the order's client order ID, so the controller method is a no-op that always resolves `true`. See the [18.0.0 changelog section](https://github.com/MetaMask/core/blob/release/1282.0.0/packages/perps-controller/CHANGELOG.md) for the full set of additions and fixes. No workspace package depends on `@metamask/perps-controller`, so nothing else in the monorepo needs a version bump or a dependency pin here. ## References - Rework fee resolver for ADR 0064 cloid-based subscription waiver: MetaMask#10294 - Fix `mergeOrderFills` dedupe key dropping real executions from history (adds `OrderFill.fillId`): MetaMask#10384 - Breaking change process: https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md ## 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) - [ ] 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 - This release publishes breaking changes; the Mobile and Extension messenger-type updates still need to be landed alongside it. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > The release exposes breaking Perps messenger types and fee-quote semantics; Mobile and Extension must land coordinated updates before or with adoption. > > **Overview** > This release PR bumps the monorepo to **1282.0.0** and publishes **`@metamask/perps-controller@18.0.0`**, cutting the `[18.0.0]` changelog section from `[Unreleased]` and updating package versions. No implementation code changes appear in the diff—the branch ships work already on `main` since **17.4.0**. > > Consumers upgrading get the **ADR-0064 subscription fee waiver rework** ([MetaMask#10294](MetaMask#10294)): blended waiver quotes tied to order notional, client-order-ID attribution (replacing subscription builder approval), new fee/subscription types and `@metamask/perps-controller/utils` exports, plus **`OrderFill.fillId`** ([MetaMask#10384](MetaMask#10384)) for deduping distinct executions. > > **Breaking:** parent messenger types must add `SubscriptionController:getBenefits` and `SubscriptionController:registerAddress`, and fee previews must pass **`FeeCalculationParams.amount`** when subscription waivers apply or quotes can diverge from submit. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cf8880f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>

Explanation
ADR 0064 replaces the old dedicated zero-fee builder with a per-user, cloid-attributed waiver. This PR updates
@metamask/perps-controllerto use that model.SubscriptionController:getBenefits. Address registration uses the structuralSubscriptionController:registerAddressaction, with the existing injected hook as a fallback. The remoteperpsSubscriptionFeeWaiverEnabledflag disables only this source.The cloid program ID remains an ADR registry dependency. Caller-supplied cloids and Scale cloids receive the fee treatment but cannot always provide a downstream-decodable attribution marker; that limitation remains documented for the schema and billing owners.
Validation
Use the Core harness from the repository root:
The diff gate passes policy suppressions, ESLint, oxfmt, and Jest. The read-only recipe passes its Jest assertions and live HyperLiquid testnet reads for positions, orders, and account state. It does not submit testnet orders.
Additional checks:
yarn buildpasses.messenger-action-types:checkpasses.changelog:validatepasses.Consumer follow-up
Clients must register the benefits and address actions and pass
FeeCalculationParams.amounttocalculateFeeswhen quoting a partial waiver. Mobile UI work remains tracked by TAT-3622.References
0064-subscription-perps-fee-waiver.md(IN REVIEW)Validation Recipe
recipe.json (0 steps — TAT-3967 subscription fee waiver — ADR 0064 rework)
{ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "TAT-3967 subscription fee waiver \u2014 ADR 0064 rework", "description": "Proves the ADR 0064 subscription fee-waiver rework in @metamask/perps-controller: blended vs. full waiver from order notional, the blend competing (and losing) in the lowest-wins comparison, a shared preview/submit formula quantized to the venue's tenths of a basis point, cloid program_id marking centralized in one chokepoint every placement path routes through, benefits hydration over SubscriptionController:getBenefits, address registration through SubscriptionController:registerAddress with an injected-hook fallback, an independent remote kill flag, and a builder-order context that no longer selects the dedicated subscription builder. Two criteria are PARTIAL rather than proven and recipe-coverage.md says why: orders carrying a caller-supplied clientOrderId and Scale rungs receive the discount without a decodable marker (AC4), and older clients may still use the injected registration hook (AC5 compatibility fallback). Ends with live headless controller reads proving the changed package still operates against HyperLiquid.", "paramsSchema": { "type": "object", "additionalProperties": false, "properties": { "account": { "type": "string", "default": "", "description": "Fixture account name, defaulting to dev1. Without a fixture, supply an EVM address for read-only inspection." }, "network": { "type": "string", "enum": [ "testnet", "mainnet" ], "default": "testnet", "description": "Network for every isolated controller read." } } }, "workflow": { "entry": "status", "nodes": { "status": { "action": "app.status", "next": "waiver-formula", "intent": "Resolve the core checkout and report headless compatibility mode" }, "waiver-formula": { "action": "command", "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts --reporters=default --verbose --no-coverage", "timeout_ms": 600000, "allow_failure": true, "next": "assert-waiver-formula-exit", "intent": "Run the shared blended-rate formula suite that preview and submit both consume" }, "assert-waiver-formula-exit": { "action": "assert_exit_code", "node": "waiver-formula", "expected": 0, "next": "assert-full-waiver", "intent": "AC1/AC3: the shared waiver formula suite must pass" }, "assert-full-waiver": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "waives the whole fee when the remaining allowance covers the order notional", "next": "assert-blended-waiver", "intent": "AC1: full waiver when remaining >= order notional is covered by a named test" }, "assert-blended-waiver": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "blends the fee by the uncovered share when the allowance is smaller than the order notional", "next": "assert-waiver-unknown-policy", "intent": "AC1: partial blend MaxFee * (1 - remaining/orderNotional) is covered by a named test" }, "resolver": { "action": "command", "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts --reporters=default --verbose --no-coverage", "timeout_ms": 900000, "allow_failure": true, "next": "assert-resolver-exit", "intent": "Run the unified fee resolver suite covering every fee source" }, "assert-resolver-exit": { "action": "assert_exit_code", "node": "resolver", "expected": 0, "next": "assert-resolver-blend-loses", "intent": "AC1/AC2/AC6/AC7: the resolver suite must pass" }, "assert-resolver-blend-loses": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "lets a rewards discount beat a partial subscription blend", "next": "assert-resolver-quantization", "intent": "AC2: a partial blend can lose the lowest-wins comparison to VIP/season" }, "assert-resolver-exhausted": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "withholds the waiver when the allowance is exhausted", "next": "assert-resolver-flag", "intent": "AC1: exhausted/ineligible/stale still fail closed" }, "assert-resolver-flag": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "drops the subscription source when the remote feature flag disables it", "next": "assert-resolver-messenger-only", "intent": "AC6: the remote kill flag removes only the subscription source" }, "provider": { "action": "command", "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts --reporters=default --verbose --no-coverage", "timeout_ms": 900000, "allow_failure": true, "next": "assert-provider-exit", "intent": "Run the provider suite covering cloid marking across every order placement path" }, "assert-provider-exit": { "action": "assert_exit_code", "node": "provider", "expected": 0, "next": "assert-provider-marks", "intent": "AC4/AC7: the cloid marking suite must pass" }, "assert-provider-marks": { "action": "assert_output", "node": "provider", "stream": "stderr", "contains": "marks the cloid with the subscription program id when subscription wins", "next": "assert-provider-unmarked", "intent": "AC4: a subscription win marks the cloid" }, "assert-provider-unmarked": { "action": "assert_output", "node": "provider", "stream": "stderr", "contains": "leaves the cloid unmarked when any other fee source wins", "next": "assert-provider-builder", "intent": "AC4: a non-subscription source leaves the cloid unmarked" }, "assert-provider-builder": { "action": "assert_output", "node": "provider", "stream": "stderr", "contains": "keeps the standard builder address when subscription wins", "next": "assert-provider-scale", "intent": "AC7: the dedicated subscription builder address is no longer selected" }, "controller": { "action": "command", "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/PerpsController.operations.test.ts --reporters=default --verbose --no-coverage", "timeout_ms": 900000, "allow_failure": true, "next": "assert-controller-exit", "intent": "Run the controller suite covering preview notional threading and address registration" }, "assert-controller-exit": { "action": "assert_exit_code", "node": "controller", "expected": 0, "next": "assert-controller-register", "intent": "AC3/AC5: the controller suite must pass" }, "assert-controller-register": { "action": "assert_output", "node": "controller", "stream": "stderr", "contains": "registers the current HyperLiquid address at preview time", "next": "assert-controller-reregister", "intent": "AC5: calculateFees registers the trading address" }, "assert-controller-reregister": { "action": "assert_output", "node": "controller", "stream": "stderr", "contains": "re-registers the trading address when the selected account changes", "next": "assert-controller-preview", "intent": "AC5: registration is re-sent on account switch" }, "assert-controller-preview": { "action": "assert_output", "node": "controller", "stream": "stderr", "contains": "resolves the preview fee against the order notional", "next": "submit", "intent": "AC3 (preview half): the preview resolves against this quote own order notional" }, "read-positions": { "action": "metamask.perps.read_positions", "account": "{{params.account}}", "network": "{{params.network}}", "mode": "all", "next": "read-orders", "intent": "Read live Perps positions from the headless controller carrying the change" }, "read-orders": { "action": "metamask.perps.read_orders", "account": "{{params.account}}", "network": "{{params.network}}", "mode": "all", "next": "read-account", "intent": "Read live Perps open orders from the headless controller carrying the change" }, "read-account": { "action": "metamask.perps.read_account", "account": "{{params.account}}", "network": "{{params.network}}", "next": "done", "intent": "Read live Perps account state from the headless controller carrying the change" }, "done": { "action": "end", "status": "pass" }, "assert-provider-scale": { "action": "assert_output", "node": "provider", "stream": "stderr", "contains": "marks every rung cloid when subscription wins and keeps the ladder recoverable", "next": "assert-provider-chase", "intent": "AC4: the Scale ladder path marks its rungs while staying recoverable" }, "submit": { "action": "command", "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/services/TradingService.test.ts --reporters=default --verbose --no-coverage", "timeout_ms": 900000, "allow_failure": true, "next": "assert-submit-exit", "intent": "Run the submit-path suite covering the order notional reaching the fee resolver" }, "assert-submit-exit": { "action": "assert_exit_code", "node": "submit", "expected": 0, "next": "assert-submit-notional", "intent": "AC3: the submit-path suite must pass" }, "assert-submit-notional": { "action": "assert_output", "node": "submit", "stream": "stderr", "contains": "resolves the submit fee against the order notional, not a bare rate", "next": "assert-submit-blend", "intent": "AC3 (submit half): the submit path resolves against the order notional, not a bare rate" }, "assert-submit-blend": { "action": "assert_output", "node": "submit", "stream": "stderr", "contains": "charges a partial blend at submit when the allowance is bounded", "next": "assert-submit-full-close", "intent": "AC3: submit charges the same partial blend the preview quotes, so the two paths agree" }, "assert-submit-full-close": { "action": "assert_output", "node": "submit", "stream": "stderr", "contains": "prices a full close from the loaded position notional", "next": "assert-submit-partial-close", "intent": "AC3: a full close prices from the loaded position, not an undefined notional" }, "assert-submit-partial-close": { "action": "assert_output", "node": "submit", "stream": "stderr", "contains": "prices a partial close from the position unit price", "next": "assert-submit-routed-close", "intent": "AC3: a partial close prices from the position unit price" }, "assert-resolver-messenger-only": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "hydrates and grants the waiver for a messenger-only client", "next": "assert-resolver-cache-preserved", "intent": "AC5: a client wiring only the SubscriptionController actions hydrates and receives the waiver" }, "assert-waiver-caller-cloid": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "preserves a caller client order ID that begins with a reserved marker", "next": "assert-waiver-blend-withheld", "intent": "AC4: a caller-supplied client order ID is never rewritten, even with a reserved prefix" }, "assert-provider-chase": { "action": "assert_output", "node": "provider", "stream": "stderr", "contains": "marks the replacement cloid after the subscription context is cleared", "next": "assert-waiver-caller-cloid", "intent": "AC4: a chase replacement marks its cloid after the live fee resolution is cleared" }, "assert-waiver-blend-withheld": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "withholds a bounded allowance when the order notional is undefined", "next": "assert-waiver-quantized", "intent": "AC1: a bounded allowance is withheld, not granted, when the order cannot be priced" }, "assert-waiver-quantized": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "quotes the venue-quantized rate the submit path charges", "next": "assert-waiver-malformed-cloid", "intent": "AC3: the preview quotes the venue-floored rate submit charges, not the raw fraction" }, "assert-submit-routed-close": { "action": "assert_output", "node": "submit", "stream": "stderr", "contains": "prices a routed close from the routed provider position", "next": "assert-submit-batch-route", "intent": "AC3: a routed close prices from the position its own route holds" }, "assert-submit-batch-route": { "action": "assert_output", "node": "submit", "stream": "stderr", "contains": "prices a batch close only from positions the route can close", "next": "read-positions", "intent": "AC3: a batch close prices only from positions its write route can close" }, "assert-resolver-cache-preserved": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "keeps a cached snapshot when a messenger-only benefits read rejects", "next": "assert-resolver-sync-throw", "intent": "AC5: a rejected messenger benefits read preserves the cached snapshot" }, "assert-resolver-sync-throw": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "keeps a cached snapshot when a benefits handler throws synchronously", "next": "assert-resolver-no-register-hook", "intent": "AC5: a synchronous handler failure preserves the cached snapshot rather than erasing it" }, "assert-resolver-no-register-hook": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "reports the gap when the client has no registration hook", "next": "assert-resolver-register-action", "intent": "AC5: a client with no registration hook is reported rather than silently unattributed" }, "assert-waiver-malformed-cloid": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "rejects a malformed cloid whose flag byte is only partly hex", "next": "assert-resolver-perps-benefit", "intent": "AC4: a malformed client order ID is not reported as fee-reduced" }, "assert-resolver-perps-benefit": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "withholds the waiver when the perps block reports no benefit", "next": "controller", "intent": "AC1: the waiver needs positive evidence of a perps benefit, not merely a present block" }, "assert-waiver-unknown-policy": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "leaves a zero rate untouched when the provider reports no fee policy", "intent": "AC3: a provider that reports no builder-fee policy keeps its own zero rate, so an omitted optional field cannot add a fee", "next": "assert-waiver-concurrent-submit" }, "assert-waiver-concurrent-submit": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "re-prices a zero provider rate left behind by a concurrent waived submit", "intent": "AC3: a preview racing a fully waived submit does not inherit that waiver", "next": "assert-waiver-nonhex-cloid" }, "assert-waiver-nonhex-cloid": { "action": "assert_output", "node": "waiver-formula", "stream": "stderr", "contains": "rejects a length-correct, prefix-matching id whose body is not hex", "intent": "AC4: the program-marker predicate rejects malformed client order IDs rather than feeding the decoder garbage", "next": "resolver" }, "assert-resolver-quantization": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "does not claim the subscription source when the blend quantizes to the full fee", "intent": "AC2/AC3: a blend that rounds to the full fee at venue precision cannot report source=subscription", "next": "assert-resolver-exhausted" }, "assert-resolver-register-action": { "action": "assert_output", "node": "resolver", "stream": "stderr", "contains": "registers through SubscriptionController for messenger-only clients", "next": "provider", "intent": "AC5: messenger-only clients can route address registration through SubscriptionController" } } } }Validation Logs
Full output (47/47 passed, pass)
Checklist
calculateFeesquoting change as BREAKING (a subscription-waived quote can now return different rates), but no client draft PRs are prepared yet. The client-side work is tracked separately (TAT-3622 for mobile UI; messenger registration and passingamountare listed under References).Screenshots/Recordings
Note
High Risk
Changes fee quoting, charging, and subscription entitlement across preview and submit paths, plus breaking messenger types—incorrect notional or benefits caching could over-grant waivers or mis-attribute fills.
Overview
Implements ADR 0064 in
@metamask/perps-controller: subscription waivers no longer use a dedicated zero-fee builder. All sources pay through the standard HyperLiquid builder, and attribution rides on client order ID (cloid) marking when subscription actually lowers the charged fee.Fee resolution now blends subscription against order notional (USD) and remaining allowance (full vs partial), competes with rewards/default at venue-quantized tenths of a basis point, and requires subscription to be strictly cheaper after quantization to win.
calculateFeesand submit paths thread the same notional (closes, batch closes, TP/SL, scale/chase, flips) so previews match what gets charged; bounded allowances are withheld when notional is unknown.Integration: benefits hydrate via
SubscriptionController:getBenefits(micro-USD → USD at the boundary); trading addresses register on preview and account switch viaSubscriptionController:registerAddressor an injected fallback. Remote flagperpsSubscriptionFeeWaiverEnabledcan disable only the subscription source.approveSubscriptionBuilderFeeis deprecated and always resolvestruewithout calling the provider.Breaking for clients:
PerpsControllerAllowedActionsadds subscription messenger actions;calculateFeesquoted rates can change when a partial waiver applies—callers should passFeeCalculationParams.amount. New exports include@metamask/perps-controller/utilsand waiver/cloid helpers (markSubscriptionCloid,hasFeeReductionAppliedFlag, etc.). HyperLiquid submission paths route orders through a single#applySubscriptionCloidchokepoint; caller-supplied cloids are never rewritten.Reviewed by Cursor Bugbot for commit 8aeed56. Bugbot is set up for automated code reviews on this repo. Configure here.