feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver [NOT-READY] - #10294
abretonc7s wants to merge 18 commits into
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
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 13149f8. Configure here.
| address, | ||
| chainId, | ||
| this.#deps.logger, | ||
| ); |
There was a problem hiding this comment.
Wrong chain ID for address registration
High Severity
registerTradingAddress builds the CAIP-10 from NetworkController's currently selected network instead of HyperLiquid's chain (eip155:999 / eip155:998). Preview and account-switch registration therefore announce the trading address on whatever chain the wallet happens to have selected, so a fill decoded off the HyperLiquid fan-out cannot match the registered identifier and profile attribution fails.
Reviewed by Cursor Bugbot for commit 13149f8. Configure here.
There was a problem hiding this comment.
Confirmed — this is a real defect, not a false positive.
registerTradingAddress builds the CAIP-10 from NetworkController:getState().selectedNetworkClientId, so the chain is whatever the wallet has selected rather than HyperLiquid's (eip155:999 / eip155:998). The existing test encodes the wrong behaviour too — it asserts /^eip155:1:0x/u, i.e. Ethereum mainnet.
Not fixed in this push, deliberately. The service has no testnet signal: isTestnet lives in PerpsController state, and RewardsIntegrationService receives only deps and the messenger. Correcting it means choosing how the HL chain reaches the service (a constructor dep, a parameter from the two call sites, or a getter) and inverting a test expectation — a design call beyond a CI-failure pass, which is what this run was scoped to.
The limitation is now recorded in the package changelog in 5fc1ecf so it is not lost: attribution is affected, while fee resolution and order placement are not.
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>


Explanation
TAT-3618 shipped the perps subscription fee waiver as a binary 0-bips discount enforced
through a dedicated approved builder address. ADR 0064 has since moved past that revision
and explicitly rejects the dedicated-builder approach — it needs a per-user approval and
gives the backend no order context. This PR reworks the
@metamask/perps-controllersideto match the current ADR text.
Blended waiver instead of binary.
RewardsIntegrationService.resolveFee()now takes anoptional order notional and derives the subscription source's effective rate from it:
0bips when the remaining allowance covers the order,MaxFee × (1 − remaining / orderNotional)otherwise.
remainingNotionalUsdwas already read but only ever passed through; it nowactually bounds the waiver.
Subscription competes rather than short-circuiting. The blended rate enters the same
lowest-wins comparison as rewards. Previously the code set
feeBips = 0unconditionallywhen the gate passed, so subscription could never lose; now a partial blend can be beaten
by a deeper VIP or season discount, and only wins when it is genuinely cheaper.
Preview and submit share one formula.
PerpsController.calculateFeesthreads theexisting
FeeCalculationParams.amountinto the resolver, andMarketDataServicere-pricesthe MetaMask fee component from the resulting resolution. The formula itself lives in one
pure helper (
src/utils/subscriptionFeeWaiver.ts) that both paths call, so a quoted feeand a charged fee cannot drift.
Attribution moves from the builder address to the cloid.
#getBuilderOrderContextnolonger swaps builders; 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, something a
dedicated 0-bips builder could not express. When subscription wins, one provider helper
stamps the order's client order ID with a reserved program marker plus a
fee_reduction_appliedflag. Every submission path routes through that helper: primaryplacement, Scale ladder, attached and standalone TP/SL, position TP/SL update, batch close,
modify/replace, and chase. Any other fee source leaves the client order ID untouched.
Two details worth flagging for review:
what lets the marking compose with the Scale ladder's own cloid: a rung keeps its
4d4d5343group marker and its rung index, so group recovery from open orders andcancel-by-client-order-ID keep working.
createScaleOrderIdentitynow zeroes the byte the flag occupies (entropy shortened from22 to 20 hex characters). Without that, random entropy would set
fee_reduction_appliedon roughly half of all unmarked ladders and they would decode downstream as waived.
Group entropy drops from 88 to 80 bits, still far beyond what ladder uniqueness needs,
and previously placed ladders stay recoverable.
SubscriptionController over the messenger. Two allowed actions are added:
SubscriptionController:getPerpsBenefits(benefits hydration, which the ADR moves off theplain DI callback) and
SubscriptionController:registerAddress(registers the current HLtrading address as CAIP-10 at preview time, re-sent after an account switch, so a fill
decoded off the HL fan-out can be attributed to a profile). They are declared structurally
because no
SubscriptionControllerpackage exists in this monorepo yet; a client that doesnot register them keeps the existing DI path and skips registration, so nothing regresses.
Independent kill switch.
perpsSubscriptionFeeWaiverEnableddisables only thesubscription source, leaving VIP, season, and the default builder fee alone (ADR Milestone
8). It fails open — an absent, malformed, or unreachable flag reads as enabled — because
silently dropping a benefit the user pays for is worse than serving it one release too long.
Dedicated builder deprecated, not deleted. AC 7 asks for removal "once cloid marking is
verified in shadow mode". That verification has not happened, so the approval machinery is
made unreachable from order construction and marked
@deprecatedacross the controller,provider, aggregated provider, and the
PerpsProvidertype.PerpsController.approveSubscriptionBuilderFeeis now a no-op that resolves
false, keeping callers building while they migrate. Deletingit is a follow-up.
Known limitation: the cloid program marker (
SUBSCRIPTION_CLOID_CONFIG.ProgramId) is aplaceholder. The registry value is an open
[TODO]in ADR 0064 and belongs to the cloidschema owners — out of scope here. It is isolated in a single constant so adopting the real
value is a one-line change; the decoder side must not be enabled against the placeholder.
Validation
perps-controllerJest suite: 80 suites, 3661 passed, 40 skipped.yarn build(ts-bridge): exit 0, with the new symbols present in the emitted output.mm-harness check diff --profile fast: policy-suppressions, ESLint, oxfmt, and Jest allpass over the 18 changed files.
yarn workspace @metamask/perps-controller run changelog:validate: pass.each acceptance criterion with live HyperLiquid testnet reads through isolated headless
controllers. Revert-sensitivity was checked rather than assumed — stashing a single source
file fails 12 asserted tests.
and this change alters the payload those orders carry.
References
0064-subscription-perps-fee-waiver.md(status: IN REVIEW)Client follow-ups this PR does not include:
SubscriptionController:getPerpsBenefitsandSubscriptionController:registerAddresson the client messenger. Until then, benefits hydration falls back to the DI callback and
address registration is skipped.
amount(order notional in USD) tocalculateFees. A preview that omits it quotesthe full-waiver rate, which can under-quote a partial blend.
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 — 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 the injected hook, 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 SubscriptionController exposes no address-registration action so only the injected hook is observed (AC5). 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": "provider", "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" } } } }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 resolution, charged builder fees, and order client IDs on live trading paths, with breaking messenger typing and quote semantics when notional is omitted.
Overview
Implements ADR 0064 in
@metamask/perps-controller: subscription fee relief is no longer a dedicated zero-fee builder plus per-user approval. It is a notional-aware blended MetaMask builder fee that competes with rewards in the same lowest-wins resolver, with preview and submit sharing one formula (including venue tenths-of-a-bip quantization).Fee quoting and submission thread order USD notional through
calculateFees(FeeCalculationParams.amount) and everyTradingServicewrite path (place, close, batch close, TP/SL, flip) so bounded allowances are not quoted or charged as full waivers. Fee previews re-price the MetaMask component from unified resolution viaapplyFeeResolution; results can exposesubscriptionWaiverKind, covered notional, andchargesMetamaskBuilderFee.Attribution stamps generated HyperLiquid client order IDs when subscription actually reduces the charged fee (
#applySubscriptionCloidacross placement, scale, chase replacements, TP/SL, batch close—not onmodify). Caller-supplied cloids are never rewritten. The standard builder address is always used at the resolved fee.Subscription integration hydrates benefits via
SubscriptionController:getBenefits(with DI fallback), optionalregisterTradingAddressat preview and on account switch, remote flagperpsSubscriptionFeeWaiverEnabled, and exports@metamask/perps-controller/utilswaiver helpers.approveSubscriptionBuilderFeeis deprecated and always resolvestruewithout calling the provider.Breaking: parent messenger types must include
SubscriptionControllerGetBenefitsAction; clients should pass order notional tocalculateFeesfor accurate subscription quotes.Reviewed by Cursor Bugbot for commit 221f5fc. Bugbot is set up for automated code reviews on this repo. Configure here.