feat(perps): consume perps controller analytics contract in MetaMask Extension#44324
feat(perps): consume perps controller analytics contract in MetaMask Extension#44324abretonc7s wants to merge 36 commits into
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Automated dev run — MANUAL-000001
Worker reportDescriptionConsume the TAT-3463 perps analytics contract from ChangelogCHANGELOG entry: null Related issuesFixes: MANUAL-000001 Manual testing steps
Screenshots/RecordingsEvidence will be added after upload. BeforeN/A — non-visual analytics contract consumption. AfterN/A — non-visual analytics contract consumption. Pre-merge author checklist
Pre-merge reviewer checklist
Validation Recipeartifacts/recipe.json{
"schema_version": 1,
"title": "MANUAL-000001 perps controller analytics contract",
"description": "Validate Extension consumes @metamask/perps-controller >=9.1 analytics contract (TAT-3463 / core#9311): package version, controller re-exports, attribution API wiring, and no duplicate client transaction analytics emissions.",
"validate": {
"workflow": {
"entry": "gate-repo-root",
"nodes": {
"gate-repo-root": {
"action": "assert_file",
"path": "package.json",
"contains": "@metamask/perps-controller",
"intent": "Ensure recipe runs from the extension repo root",
"next": "ac1-assert-package-version"
},
"ac1-assert-package-version": {
"action": "command",
"cmd": "node -e \"const v=require('./node_modules/@metamask/perps-controller/package.json').version; if(!/^9\\\\.(?:[1-9]|\\\\d{2,})\\\\./.test(v) && !/^9\\\\.(?:[1-9]|\\\\d{2,})$/.test(v)) { console.error('unexpected version', v); process.exit(1);} const c=require('@metamask/perps-controller/constants/eventNames'); if(!c.PERPS_EVENT_PROPERTY.ENTRY_POINT || !c.PERPS_EVENT_PROPERTY.HL_FEE_RATE || !c.PERPS_EVENT_PROPERTY.BULK_ACTION_ID || c.PERPS_EVENT_VALUE.STATUS.SUBMITTED!=='submitted'){ console.error('missing TAT-3463 contract keys'); process.exit(1);} console.log('perps-controller', v);\"",
"intent": "AC1: Extension depends on perps-controller >=9.1 with TAT-3463 analytics contract exports",
"next": "ac2-assert-controller-reexports"
},
"ac2-assert-controller-reexports": {
"action": "assert_file",
"path": "shared/constants/perps-events.ts",
"contains": "from '@metamask/perps-controller'",
"intent": "AC2: perps-events.ts imports controller analytics constants (no divergent hand-maintained mirror)",
"next": "ac2-assert-no-local-timestamp-mirror"
},
"ac2-assert-no-local-timestamp-mirror": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('shared/constants/perps-events.ts','utf8'); if(t.includes(\\\"TIMESTAMP: 'perps_timestamp'\\\")){ console.error('local TIMESTAMP mirror still present'); process.exit(1);} if(!t.includes(\\\"from '@metamask/perps-controller'\\\")){ console.error('missing controller import'); process.exit(1);} console.log('re-export ok');\"",
"intent": "AC2: Confirm TIMESTAMP is not locally mirrored as perps_timestamp",
"next": "ac3-assert-attribution-api-wiring"
},
"ac3-assert-attribution-api-wiring": {
"action": "assert_file",
"path": "app/scripts/messenger-client-init/perps-controller-init.ts",
"contains": "perpsSetAttributionContext",
"intent": "AC3: Background API exposes controller setAttributionContext",
"next": "ac3-assert-attribution-provider"
},
"ac3-assert-attribution-provider": {
"action": "assert_file",
"path": "ui/providers/perps/PerpsAttributionContext.tsx",
"contains": "perpsSetAttributionContext",
"intent": "AC3: UI attribution provider syncs UTM/entry context into controller",
"next": "ac3-assert-layout-provider"
},
"ac3-assert-layout-provider": {
"action": "assert_file",
"path": "ui/pages/perps/perps-layout.tsx",
"contains": "PerpsAttributionProvider",
"intent": "AC3: Perps layout mounts attribution provider for entry/discovery/UTM propagation",
"next": "ac3-assert-metrics-merge-utm"
},
"ac3-assert-metrics-merge-utm": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const infra=fs.readFileSync('app/scripts/controllers/perps/infrastructure.ts','utf8'); const init=fs.readFileSync('app/scripts/messenger-client-init/perps-controller-init.ts','utf8'); if(!infra.includes('mergeAttributionContext')){ console.error('infrastructure trackPerpsEvent missing mergeAttributionContext'); process.exit(1);} if(!init.includes('mergeAttributionContext:')){ console.error('init missing mergeAttributionContext wiring'); process.exit(1);} console.log('utm merge wired');\"",
"intent": "AC3: trackPerpsEvent merges controller UTM attribution before MetaMetrics emission",
"next": "ac4-assert-tracking-data-builder"
},
"ac4-assert-tracking-data-builder": {
"action": "assert_file",
"path": "ui/hooks/perps/usePerpsAttribution.ts",
"contains": "entryPoint",
"intent": "AC4: trackingData builder attaches entryPoint/discoverySource for controller submitted+terminal events",
"next": "ac4-assert-cancel-tracking-data"
},
"ac4-assert-cancel-tracking-data": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/components/app/perps/cancel-order/cancel-order-modal.tsx','utf8'); if(!t.includes('trackingData: buildTrackingData')){ console.error('cancel missing trackingData'); process.exit(1);} console.log('cancel trackingData ok');\"",
"intent": "AC4: Cancel passes trackingData so controller cancel events keep entry/discovery",
"next": "ac4-assert-place-hl-fee-rate"
},
"ac4-assert-place-hl-fee-rate": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const order=fs.readFileSync('ui/pages/perps/perps-order-entry-page.tsx','utf8'); const flip=fs.readFileSync('ui/components/app/perps/reverse-position/reverse-position-modal.tsx','utf8'); const hlCount=(order.match(/hlFeeRate: closeFeeRate/g)||[]).length; if(hlCount<3){ console.error('expected hlFeeRate on close+place+modify paths, found', hlCount); process.exit(1);} if(!flip.includes('hlFeeRate: feeRate')){ console.error('flip missing hlFeeRate'); process.exit(1);} console.log('hlFeeRate parity ok', hlCount);\"",
"intent": "AC4: Place/modify and flip trackingData include optional hlFeeRate",
"next": "ac5-assert-no-duplicate-order-entry-close"
},
"ac5-assert-no-duplicate-order-entry-close": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/pages/perps/perps-order-entry-page.tsx','utf8'); if(t.includes('MetaMetricsEventName.PerpsPositionCloseTransaction') || t.includes('MetaMetricsEventName.PerpsTradeTransaction') || t.includes('MetaMetricsEventName.PerpsRiskManagement')){ console.error('order-entry still emits duplicate transaction analytics'); process.exit(1);} console.log('order-entry deduped');\"",
"intent": "AC5: Order-entry no longer emits client Trade/Close/Risk transaction events",
"next": "ac5-assert-no-duplicate-cancel"
},
"ac5-assert-no-duplicate-cancel": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/components/app/perps/cancel-order/cancel-order-modal.tsx','utf8'); if(t.includes('PerpsOrderCancelTransaction')){ console.error('cancel modal still emits duplicate cancel analytics'); process.exit(1);} console.log('cancel deduped');\"",
"intent": "AC5: Cancel modal does not emit duplicate PerpsOrderCancelTransaction",
"next": "ac5-assert-no-duplicate-close-modal"
},
"ac5-assert-no-duplicate-close-modal": {
"action": "command",
"cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/components/app/perps/close-position/close-position-modal.tsx','utf8'); if(t.includes('PerpsPositionCloseTransaction')){ console.error('close modal still emits duplicate close analytics'); process.exit(1);} console.log('close modal deduped');\"",
"intent": "AC5: Close modal does not emit duplicate PerpsPositionCloseTransaction",
"next": "teardown-end"
},
"teardown-end": {
"action": "end",
"status": "pass",
"intent": "Recipe complete"
}
}
}
}
} |
✨ Files requiring CODEOWNER review ✨🕵️ @MetaMask/extension-privacy-reviewers (1 files, +4 -0)
👨🔧 @MetaMask/perps (44 files, +2982 -588)
📜 @MetaMask/policy-reviewers (12 files, +96 -24)
Tip Follow the policy review process outlined in the LavaMoat Policy Review Process doc before expecting an approval from Policy Reviewers. 🧪 @MetaMask/qa (1 files, +4 -0)
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Co-authored-by: Cursor <cursoragent@cursor.com>
aed93b5 to
cf70bb7
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
58b0939 to
d09d491
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
…er visit" This reverts commit 1b01642.
Signed deeplinks list only routing params in `sig_params`, so `parse` canonicalizes the URL down to those before the route handler runs. Campaign `utm_*` are appended unsigned and were dropped by that step, so `withDeeplinkAttribution` forwarded `source=deeplink` but never the utm params, and the client `PERPS_SCREEN_VIEWED` carried source without utm. Set `handlerSearchParams: 'original'` on the perps deeplink routes so the handler reads the full incoming params. These routes only open read-only screens, so honoring unsigned routing params is safe (matches the trending/predict/batch-sell routes). Adds a parse-level regression test proving utm survives a signed link whose sig_params omit them.
Builds ready [ab89a58]
⚡ Performance Benchmarks (Total: 🟢 8 pass · 🟡 16 warn · 🔴 0 fail)
Bundle sizes
|
The deeplink PERPS_SCREEN_VIEWED fired before the provider's UTM-sync effect back-filled utmAttribution state (React runs effects after render, and warm markets make the fire-once screen view eligible immediately), so the fire-once guard blocked the enriched re-fire and the event carried source=deeplink but no utm_*. Derive UTM and the deeplink source from the current locationSearch at render time in screenViewedAttribution so they are available synchronously on the first emit; the sticky store still covers later in-app navigations whose search no longer carries utm. Adds a race regression test that seeds the utm-bearing search after mount. Also clears the Test lint job (jsdoc @param, import-x/no-duplicates, snake_case naming-convention headers on analytics/URL test literals, jest/no-if) and updates the Sentry UI-state and state-logs e2e fixtures for the controller's new recentlyViewedMarkets state.
Adds a router-driven regression matching the real interstitial flow: the attribution provider is already mounted at a non-utm perps route when the deeplink Continue navigates to the utm-bearing market route. The provider's locationSearch updates in place (no remount) and the fire-once screen view fires immediately, so the first emit must get UTM from the render-time derivation of the current search rather than the effect-back-filled state. Proven non-vacuous: without the render-time union the first emit drops utm.
The entry PERPS_SCREEN_VIEWED fires fire-once on the first eligible render, but react-router applies the destination useLocation().search one render AFTER window.location.hash is already correct — so deriving utm from the provider's locationSearch (render or state) still reads empty at that instant and the enriched re-fire is blocked. Read utm and the deeplink source directly from the current hash query at emit time in usePerpsEventTracking, winning over the provider store; the sticky store still covers later in-app navigations whose hash no longer carries utm. Adds a regression that seeds window.location.hash with utm while the provider search is empty, asserting the first emit carries utm + source.
Make the emit-time window.location.hash read the authoritative UTM source for every PERPS_SCREEN_VIEWED, not just when a provider store is present. A stale or absent provider context (the emitting instance's locationSearch may never carry the perps-route search, and the fire-once guard blocks any enriched re-fire) must not drop UTM — the hash is always current at emit. Adds a regression proving the declarative fire-once emit stamps utm from the hash even with no provider wrapping the call site.
Builds ready [ef0f86a]
⚡ Performance Benchmarks (Total: 🟢 16 pass · 🟡 8 warn · 🔴 0 fail)
Bundle sizes
|
A deeplink to the wallet perps tab mounts one PerpsAttributionProvider;
navigating deeper into /perps/* ("See all") mounts a fresh provider on a
bare in-app URL, whose client PERPS_SCREEN_VIEWED lost the entry utm (the
hash no longer carries it and the sticky store was per-instance). Add a
module-level, session-scoped attribution store (lifetime = UI session,
mirroring the controller's global attribution): the first utm-bearing
entry seeds it and later provider instances inherit it. The emit-time hash
read still wins when a fresh deeplink is present. Adds a regression: a new
provider on a bare URL carries the entry-session utm on its screen view.
Builds ready [a23b47f]
⚡ Performance Benchmarks (Total: 🟢 16 pass · 🟡 7 warn · 🔴 1 fail)
Bundle sizes
|
The controller only emits the transaction `action` property when `trackingData.tradeAction` is set (and forwards it verbatim), so the controller-owned trade executed/failed events were silently dropping `action` — a regression versus the removed client emissions. Add a shared `derivePerpsTradeAction(existingDirection, orderDirection)` helper (create_position / increase_exposure / flip_long_to_short / flip_short_to_long), pass its result as `trackingData.tradeAction` on all perpsPlaceOrder paths, and use the same helper for the client PERPS_TRANSACTION_CONSIDERED action so the considered and executed events agree. CONSIDERED previously derived from `hasPosition` only, mislabeling an opposite-side order against an existing position as increase_exposure instead of a flip. The considered-debounce still gates on a stable `positionDirection` primitive to avoid position-stream churn resets. Tests: derivation (create/increase/both flips), buildTrackingData passthrough, and CONSIDERED flip cases (long->short, short->long) plus a place-order tradeAction assertion.
…ch intent
edit-margin: the terminal margin-risk "failed" event for the controller
`{ success: false }` branch is emitted by perps-controller from its next
release (core #9471); note it so no client fallback is added (would
double-emit once that ships).
update-tpsl: document that the delayed positions refetch is intentionally
not retained/cleared on unmount — it must run ~2.5s after the modal closes
(covered by an existing test) and only pushes into the global stream
manager, so there is no unmounted-component state update to guard.
…t scope, catch hygiene - Emit the error screen view on the market-not-found state of both the market-detail and order-entry pages (screen_type=error, error_type=market_not_found, correct screen_name), which previously rendered a displayed error without any funnel event. - Make source=deeplink PER-ENTRY: a fresh PerpsAttributionProvider mount on a non-deeplink URL reports its own source instead of inheriting the prior entry's deeplink flag. UTM stays session-sticky (last-touch), matching mobile. - Re-assert the asset_details entry point on market-detail mount so trackingData for close/cancel/TP-SL/margin taken there reflects asset_details even after returning from the order-entry screen (which sets trade_screen on the shared provider). - perps-view: capture the close-all refresh, close-all, and cancel-all catch errors via captureException instead of swallowing them. Tests: market-not-found error views (both pages), cross-provider utm-sticky but per-entry source, and asset_details entry-point re-assert.
The close-all summary event (number_positions_closed) is emitted by the controller's batch-close summary from the next perps-controller release (core #9471); document it at the success branch so no client fallback is added (would double-count).
Builds ready [e924e78]
⚡ Performance Benchmarks (Total: 🟢 15 pass · 🟡 7 warn · 🔴 2 fail)
Bundle sizes
|
geositta
left a comment
There was a problem hiding this comment.
I am requesting changes for two findings: direction changes can emit a considered event for a seeded amount without a new size edit, and partial UTM updates produce different attribution between client and controller events.
| // Reset the considered-event gating refs when the market or order context | ||
| // changes, so a prior edit doesn't make the next market's seeded default fire | ||
| // as a user consideration (the page is reused across symbols). | ||
| useEffect(() => { |
There was a problem hiding this comment.
Changing direction resets usePerpsOrderForm to its seeded amount, but this effect does not reset hasUserEditedSizeRef or lastInputMethodRef.
After editing size and switching Long/Short, the seeded amount can emit PERPS_TRANSACTION_CONSIDERED without another size interaction. Please reset both refs when orderDirection changes and add a timer-based regression test.
There was a problem hiding this comment.
Fixed in 10dc57c — the reset effect now also depends on orderDirection, so switching Long/Short clears hasUserEditedSizeRef/lastInputMethodRef and the reseeded default amount cannot emit CONSIDERED without a new size interaction. Added a fake-timer regression test (edit size → switch direction → advance 1s → no CONSIDERED until a fresh edit).
| // only means controller-emitted events miss UTM enrichment; the client | ||
| // still merges the mirrored context, so log for visibility rather than | ||
| // swallow. | ||
| submitRequestToBackground('perpsSetAttributionContext', [ |
There was a problem hiding this comment.
The client accumulates partial UTM updates at line 264, but this call forwards only the latest utmContext. In @metamask/perps-controller, setAttributionContext stores { ...context } directly in its private attribution field, replacing the entire controller-side attribution context rather than merging it.
After utm_source=A&utm_medium=B, followed by utm_source=C, client screen-view events report C/B, while controller-emitted events report only C. After rememberSessionUtm(utmContext), forward the accumulated sessionUtmAttribution to the controller and test consecutive partial UTM updates.
This analytics only mismatch requires two partial UTM entries in one UI session; ordinary in-app navigation without UTM parameters does not trigger it.
There was a problem hiding this comment.
Fixed in 10dc57c — rememberSessionUtm now returns the merged accumulated context, and we forward that (not just the latest partial) to perpsSetAttributionContext, so the controllers replace-semantics stay in sync with the client-side accumulation. Added a test asserting that after utm_source=A&utm_medium=B then utm_source=C, the second background call receives the merged { utmSource: C, utmMedium: B }.
|
|
||
| // Market-not-found renders a displayed error state (see the `!market` branch | ||
| // below); emit the error screen view for that funnel state. | ||
| usePerpsEventTracking({ |
There was a problem hiding this comment.
This new error event can fire alongside the existing asset_details screen event at line 405 because that event does not require market to exist. For an unknown symbol, one rendered error screen therefore produces two screen view events. Gate the normal event on Boolean(market), add resetKey: decodedSymbol here so consecutive invalid symbols are tracked, and test that each invalid symbol produces exactly one error event.
There was a problem hiding this comment.
Fixed in 10dc57c — the market memo is now computed before the asset_details screen view, whose condition gains Boolean(market), and the error view gains resetKey: decodedSymbol. An unknown symbol now emits exactly one error screen view and zero asset_details views, and consecutive invalid symbols each track. Added tests covering both the single-error/no-asset-details case and the per-symbol re-arm.
… and market-not-found - Reset considered-event refs on orderDirection change so a Long/Short switch does not let the reseeded default amount emit PERPS_TRANSACTION_CONSIDERED without a fresh size interaction. - Forward the accumulated session UTM (not just the latest partial) to the controller, since setAttributionContext replaces its stored context wholesale. - Gate the asset_details screen view on the market existing and re-arm the market-not-found error view per symbol, so an unknown symbol emits exactly one error screen view (not two).
Builds ready [10dc57c]
⚡ Performance Benchmarks (Total: 🟢 17 pass · 🟡 7 warn · 🔴 1 fail)
Bundle sizes
|
Builds ready [f372672]
⚡ Performance Benchmarks (Total: 🟢 15 pass · 🟡 10 warn · 🔴 0 fail)
Bundle sizes
|
…override @nktkas/hyperliquid's HTTP transport abort helper (_abort.js) calls the bare globals setTimeout/clearTimeout. Under the webpack MV3 LavaMoat SES sandbox the @metamask/perps-controller compartment (and its bundled @nktkas/hyperliquid subtree) was not granted these globals, so cancelling a request threw "clearTimeout is not a function" — surfacing as PerpsStreamManager "Failed to fetch account/markets" and degrading perps data loading in e2e Chrome builds. The MV3 generated policy.json never captured the perps-controller dependency subtree (mv2 and browserify policies do). Grant the timer/fetch globals via the MV3 policy-override.json (merged into the runtime policy by @lavamoat/webpack's loadPolicy -> applyOverride), mirroring the global sets the analyzer already records for these packages in the mv2 policy. Override-only keeps the change drift-safe: it does not alter the generated policy.json the validate job checks.
Resolve MV3 LavaMoat policy-override conflicts by keeping both intents: main's new override entries (assets-controllers tanstack query-core, gridplus-sdk zod) alongside our perps-controller/@nktkas/hyperliquid timer globals. No other conflicts.
Builds ready [71e5e28]
⚡ Performance Benchmarks (Total: 🟢 15 pass · 🟡 9 warn · 🔴 1 fail)
Bundle sizes
|
geositta
left a comment
There was a problem hiding this comment.
Approve One analytics issue remains in order entry: an unknown symbol can still emit both trading and error, and the error hook does not reset between invalid symbols. This is confined to invalid or delisted market routes, but it should probably be corrected here or tracked for immediate follow-up.
The committed webpack MV3 policy.json files were stale and lacked the @metamask/perps-controller subtree, causing the validate-lavamoat-policies MV3 job to fail after main was merged in. Regenerating the policy via static analysis captures the perps subtree (including the setTimeout/clearTimeout/fetch bare-global refs the SDK's transport layer uses), so the policy-override.json entries added earlier to grant those timer globals are redundant. A no-override regen produces a byte-identical policy.json across all four build types, so the perps override entries are removed while main's zod/tanstack entries are kept.
|
Builds ready [960d3aa]
⚡ Performance Benchmarks (Total: 🟢 14 pass · 🟡 11 warn · 🔴 0 fail)
Bundle sizes
|



Description
Adopts the controller-owned perps analytics contract (
@metamask/perps-controller) and completes the assigned analytics tickets.Beyond the initial contract adoption (re-export controller event/property/value defs, attribution APIs incl.
mergeAttributionContext,trackingData/hlFeeRateon place/close/cancel/flip, removal of duplicate client-side transaction emissions so the controller TradingService owns submitted+terminal analytics, LavaMoat policy updates), this now also wires:PERPS_SCREEN_VIEWED() andsource=deeplinkwatchlistedon the asset_detail screen onlyscreen_type=error+error_type+screen_nameon every displayed-error pathbutton_clicked(close/reduce_exposure) +button_locationon position_closesort_applied/filter_appliedwithsort_field/sort_direction/filter_categoryPERPS_TRANSACTION_CONSIDERED(debounced, gated to genuine user size edits) + trading-screen defaultsscreen_type=add_margin/remove_marginon margin modal openHyperliquid SDK / patch
perps-controller@9.2.1's published CommonJS build is broken:@nktkas/hyperliquidships.d.tswith anamd-moduledirective hardcoding an absolute CI path, whichts-bridgeturns intorequire("file:///home/runner/.../mod.ts")→Cannot find module. This PR carries a temporary yarn patch rewriting it to the bare specifier. Fixed upstream in core 9.2.2 (MetaMask/core#9471, rootresolutionspatch); this patch is removed once 9.2.2 is published. Upstream report: nktkas/hyperliquid#165Deferred / N/A
order_execution_latency_msand per-sessionenvironment_typeon background (controller-emitted) tx events land with the 9.2.2 bump (client-emitted events already carry correctenvironment_type).PERPS_TRADE_QUOTE_RECEIVED(no async pay-with-token quote — fee calc is synchronous), payment-token-selector-dismissed (no such selector).Changelog
CHANGELOG entry: null
Related issues
Refs: MetaMask/core#9311, MetaMask/core#9471
Manual testing steps
@metamask/perps-controller.status: submittedthen terminal) and that the UI no longer double-fires the same transaction event names for those flows.utm_*orsource=query params and confirm subsequent order lifecycle events include attribution fields when present.Screenshots/Recordings
Non-visual analytics contract migration — no UI screenshots; recipe proves package/API/source assertions.
Pre-merge author checklist
Pre-merge reviewer checklist
Validation Recipe
artifacts/recipe.json
{ "schema_version": 1, "title": "MANUAL-000001 perps controller analytics contract", "description": "Validate Extension consumes @metamask/perps-controller >=9.1 analytics contract (/ core#9311): package version, controller re-exports, attribution API wiring, and no duplicate client transaction analytics emissions.", "validate": { "workflow": { "entry": "gate-repo-root", "nodes": { "gate-repo-root": { "action": "assert_file", "path": "package.json", "contains": "@metamask/perps-controller", "intent": "Ensure recipe runs from the extension repo root", "next": "ac1-assert-package-version" }, "ac1-assert-package-version": { "action": "command", "cmd": "node -e \"const v=require('./node_modules/@metamask/perps-controller/package.json').version; if(!/^9\\\\.(?:[1-9]|\\\\d{2,})\\\\./.test(v) && !/^9\\\\.(?:[1-9]|\\\\d{2,})$/.test(v)) { console.error('unexpected version', v); process.exit(1);} const c=require('@metamask/perps-controller/constants/eventNames'); if(!c.PERPS_EVENT_PROPERTY.ENTRY_POINT || !c.PERPS_EVENT_PROPERTY.HL_FEE_RATE || !c.PERPS_EVENT_PROPERTY.BULK_ACTION_ID || c.PERPS_EVENT_VALUE.STATUS.SUBMITTED!=='submitted'){ console.error('missing contract keys'); process.exit(1);} console.log('perps-controller', v);\"", "intent": "AC1: Extension depends on perps-controller >=9.1 with analytics contract exports", "next": "ac2-assert-controller-reexports" }, "ac2-assert-controller-reexports": { "action": "assert_file", "path": "shared/constants/perps-events.ts", "contains": "from '@metamask/perps-controller'", "intent": "AC2: perps-events.ts imports controller analytics constants (no divergent hand-maintained mirror)", "next": "ac2-assert-no-local-timestamp-mirror" }, "ac2-assert-no-local-timestamp-mirror": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('shared/constants/perps-events.ts','utf8'); if(t.includes(\\\"TIMESTAMP: 'perps_timestamp'\\\")){ console.error('local TIMESTAMP mirror still present'); process.exit(1);} if(!t.includes(\\\"from '@metamask/perps-controller'\\\")){ console.error('missing controller import'); process.exit(1);} console.log('re-export ok');\"", "intent": "AC2: Confirm TIMESTAMP is not locally mirrored as perps_timestamp", "next": "ac3-assert-attribution-api-wiring" }, "ac3-assert-attribution-api-wiring": { "action": "assert_file", "path": "app/scripts/messenger-client-init/perps-controller-init.ts", "contains": "perpsSetAttributionContext", "intent": "AC3: Background API exposes controller setAttributionContext", "next": "ac3-assert-attribution-provider" }, "ac3-assert-attribution-provider": { "action": "assert_file", "path": "ui/providers/perps/PerpsAttributionContext.tsx", "contains": "perpsSetAttributionContext", "intent": "AC3: UI attribution provider syncs UTM/entry context into controller", "next": "ac3-assert-layout-provider" }, "ac3-assert-layout-provider": { "action": "assert_file", "path": "ui/pages/perps/perps-layout.tsx", "contains": "PerpsAttributionProvider", "intent": "AC3: Perps layout mounts attribution provider for entry/discovery/UTM propagation", "next": "ac3-assert-metrics-merge-utm" }, "ac3-assert-metrics-merge-utm": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const infra=fs.readFileSync('app/scripts/controllers/perps/infrastructure.ts','utf8'); const init=fs.readFileSync('app/scripts/messenger-client-init/perps-controller-init.ts','utf8'); if(!infra.includes('mergeAttributionContext')){ console.error('infrastructure trackPerpsEvent missing mergeAttributionContext'); process.exit(1);} if(!init.includes('mergeAttributionContext:')){ console.error('init missing mergeAttributionContext wiring'); process.exit(1);} console.log('utm merge wired');\"", "intent": "AC3: trackPerpsEvent merges controller UTM attribution before MetaMetrics emission", "next": "ac4-assert-tracking-data-builder" }, "ac4-assert-tracking-data-builder": { "action": "assert_file", "path": "ui/hooks/perps/usePerpsAttribution.ts", "contains": "entryPoint", "intent": "AC4: trackingData builder attaches entryPoint/discoverySource for controller submitted+terminal events", "next": "ac4-assert-cancel-tracking-data" }, "ac4-assert-cancel-tracking-data": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/components/app/perps/cancel-order/cancel-order-modal.tsx','utf8'); if(!t.includes('trackingData: buildTrackingData')){ console.error('cancel missing trackingData'); process.exit(1);} console.log('cancel trackingData ok');\"", "intent": "AC4: Cancel passes trackingData so controller cancel events keep entry/discovery", "next": "ac4-assert-place-hl-fee-rate" }, "ac4-assert-place-hl-fee-rate": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const order=fs.readFileSync('ui/pages/perps/perps-order-entry-page.tsx','utf8'); const flip=fs.readFileSync('ui/components/app/perps/reverse-position/reverse-position-modal.tsx','utf8'); const hlCount=(order.match(/hlFeeRate: closeFeeRate/g)||[]).length; if(hlCount<3){ console.error('expected hlFeeRate on close+place+modify paths, found', hlCount); process.exit(1);} if(!flip.includes('hlFeeRate: feeRate')){ console.error('flip missing hlFeeRate'); process.exit(1);} console.log('hlFeeRate parity ok', hlCount);\"", "intent": "AC4: Place/modify and flip trackingData include optional hlFeeRate", "next": "ac5-assert-no-duplicate-order-entry-close" }, "ac5-assert-no-duplicate-order-entry-close": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/pages/perps/perps-order-entry-page.tsx','utf8'); if(t.includes('MetaMetricsEventName.PerpsPositionCloseTransaction') || t.includes('MetaMetricsEventName.PerpsTradeTransaction') || t.includes('MetaMetricsEventName.PerpsRiskManagement')){ console.error('order-entry still emits duplicate transaction analytics'); process.exit(1);} console.log('order-entry deduped');\"", "intent": "AC5: Order-entry no longer emits client Trade/Close/Risk transaction events", "next": "ac5-assert-no-duplicate-cancel" }, "ac5-assert-no-duplicate-cancel": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/components/app/perps/cancel-order/cancel-order-modal.tsx','utf8'); if(t.includes('PerpsOrderCancelTransaction')){ console.error('cancel modal still emits duplicate cancel analytics'); process.exit(1);} console.log('cancel deduped');\"", "intent": "AC5: Cancel modal does not emit duplicate PerpsOrderCancelTransaction", "next": "ac5-assert-no-duplicate-close-modal" }, "ac5-assert-no-duplicate-close-modal": { "action": "command", "cmd": "node -e \"const fs=require('fs'); const t=fs.readFileSync('ui/components/app/perps/close-position/close-position-modal.tsx','utf8'); if(t.includes('PerpsPositionCloseTransaction')){ console.error('close modal still emits duplicate close analytics'); process.exit(1);} console.log('close modal deduped');\"", "intent": "AC5: Close modal does not emit duplicate PerpsPositionCloseTransaction", "next": "teardown-end" }, "teardown-end": { "action": "end", "status": "pass", "intent": "Recipe complete" } } } } }Note
Medium Risk
Touches perps trading analytics and background metrics wiring across many UI flows; incorrect deduping or attribution could skew Segment data, though trade execution still goes through the controller.
Overview
Adopts
@metamask/perps-controller@9.2.1(with a temporary Yarn patch fixing brokenrequire("file:///…/mod.ts")for@nktkas/hyperliquidin the published CJS build) and aligns Extension perps analytics with the controller-owned contract.Contract & background:
shared/constants/perps-events.tsnow re-exports controller property/value enums (plus Extension compatibility aliases). BackgroundtrackPerpsEventmerges controller UTM via optionalmergeAttributionContext, wired fromperps-controller-inittogether with newperpsSet/Get/Clear/MergeAttributionContextAPIs.Attribution end-to-end: Deeplink handlers stamp
source=deeplinkand forwardutm_*(including on signed links viahandlerSearchParams: 'original'). UI addsPerpsAttributionProvider/usePerpsAttributionto sync UTM and buildtrackingData(entry/discovery, VIP,hlFeeRate,tradeAction) on place/close/cancel/flip/TP-SL.Deduped analytics: Client modals and close-all flows stop emitting trade/close/cancel/risk transaction events where the controller already emits submitted+terminal events; UI keeps
PerpsErrorand addstrackPerpsErrorScreenViewedon displayed-error paths. LavaMoat policies and Jestmetamask-perps-controllermocks are expanded to match.Reviewed by Cursor Bugbot for commit 960d3aa. Bugbot is set up for automated code reviews on this repo. Configure here.