@deeeed/metamask-harness 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.25.0 - 2026-07-30
6
+
7
+ ### Added
8
+
9
+ - Extension and Mobile recipes can start a local analytics collector, set MetaMetrics consent through each app's native state boundary, and assert captured event cardinality and properties.
10
+ - Extension UI actions record default screen and visibility observations in recipe traces.
11
+
12
+ ### Changed
13
+
14
+ - Updated to Recipe Protocol 0.14 and `@farmslot/recipe-harness` 0.10.3 for trustworthy observation, artifact, and hardened-page CDP behavior.
15
+
16
+ ### Fixed
17
+
18
+ - Analytics capture rejects foreign collectors, replaces its own previous listener safely, and reverses the collector port to selected Android devices.
19
+ - Analytics captures honor the selected runtime directory and store telemetry in private, symlink-safe files.
20
+ - Analytics collector startup executes only plan-bound bundled bytes; opt-out consent defaults, typed property assertions, and long settle windows retain their declared semantics.
21
+ - Extension analytics consent setup calls the background controller over a temporary Extension port without navigating through unrelated Settings UI.
22
+ - Recipe evidence screenshots and recordings publish atomically with private permissions and reject symlink destinations.
23
+ - Explicit Extension video recording waits for a captured frame and fails the run when the recorder cannot publish a valid MP4 instead of reporting a proof without its requested evidence.
24
+ - Extension fixture seeding evaluates through raw CDP so LavaMoat scuttling cannot break live launch setup.
25
+ - Extension launch output prints a runnable `runtime-launch` recovery command, including a selected Chrome profile, instead of suggesting unsupported or incomplete `launch` flags.
26
+ - `runtime-launch` distinguishes launch failures from post-launch verification failures and points at the failing evidence.
27
+ - Source-checkout dependency bootstrap verifies every runtime dependency and its declared version before declaring the harness ready, while preserving explicit local protocol package links.
28
+ - Source checkouts run newer TypeScript sources instead of silently executing an older compiled `dist/`.
29
+
5
30
  ## 0.24.0 - 2026-07-30
6
31
 
7
32
  ### Added
@@ -279,10 +279,10 @@ if [ -n "$REMOTE_FLAGS" ] && [ "$REMOTE_FLAGS_APPLIED" != "true" ]; then
279
279
  fi
280
280
 
281
281
  echo "Extension live validation command:"
282
- display_args=(mm-harness launch --verify --target "$TARGET")
283
- $LAUNCH_EXISTING_DIST && display_args+=(--launch-existing-dist)
282
+ display_args=(mm-harness runtime-launch --adapter extension --target "$TARGET" --cdp-port "$CDP_PORT")
284
283
  $START_WATCH && display_args+=(--start-watch)
285
284
  [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flag "$REMOTE_FLAGS")
285
+ [ -n "$CHROME_USER_DATA_DIR" ] && display_args+=(--chrome-user-data-dir "$CHROME_USER_DATA_DIR")
286
286
  printf ' '
287
287
  printf '%q ' "${display_args[@]}"
288
288
  printf '\n'
@@ -708,11 +708,10 @@ async function waitForWalletScreen(page) {
708
708
  const unlockSelector = '[data-testid="unlock-password"]';
709
709
  const deadline = Date.now() + 45000;
710
710
  while (Date.now() < deadline) {
711
- const fatalStartup = await page
712
- .evaluate(() => {
713
- const text = document.body?.innerText || '';
714
- return text.includes('MetaMask had trouble starting') ? text.slice(0, 500) : null;
715
- })
711
+ const fatalStartup = await evaluateViaCdp(page, () => {
712
+ const text = document.body?.innerText || '';
713
+ return text.includes('MetaMask had trouble starting') ? text.slice(0, 500) : null;
714
+ })
716
715
  .catch(() => null);
717
716
  if (fatalStartup) {
718
717
  return { state: 'fatal', selector: null, detail: fatalStartup };
@@ -733,24 +732,51 @@ async function waitForWalletScreen(page) {
733
732
  return { state: 'unknown', selector: null };
734
733
  }
735
734
 
735
+ async function evaluateViaCdp(page, callback, argument) {
736
+ const session = await page.context().newCDPSession(page);
737
+ const invocation = argument === undefined
738
+ ? `(${callback.toString()})()`
739
+ : `(${callback.toString()})(${JSON.stringify(argument)})`;
740
+ try {
741
+ const response = await session.send('Runtime.evaluate', {
742
+ expression: invocation,
743
+ awaitPromise: true,
744
+ returnByValue: true,
745
+ });
746
+ if (response.exceptionDetails) {
747
+ const detail =
748
+ response.exceptionDetails.exception?.description ??
749
+ response.exceptionDetails.text ??
750
+ 'unknown evaluation failure';
751
+ throw new Error(`CDP evaluation failed: ${detail}`);
752
+ }
753
+ return response.result?.value;
754
+ } finally {
755
+ await session.detach().catch(() => {});
756
+ }
757
+ }
758
+
736
759
  async function attemptUnlock(page, password) {
737
760
  // Selector ladder: current testids first, then the generic selectors the CDP
738
761
  // unlock action uses (proven against the same build). A freshly seeded vault
739
762
  // can also still be initializing, so a single attempt is not conclusive —
740
763
  // the caller retries.
741
- const filled = await page.evaluate((pw) => {
764
+ const filled = await evaluateViaCdp(page, (pw) => {
742
765
  const input =
743
766
  document.querySelector('[data-testid="unlock-password"]') ??
744
767
  document.querySelector('input[type="password"]');
745
768
  if (!input) return false;
746
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
769
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value').set;
747
770
  setter.call(input, pw);
748
- input.dispatchEvent(new Event('input', { bubbles: true }));
749
- input.dispatchEvent(new Event('change', { bubbles: true }));
771
+ for (const type of ['input', 'change']) {
772
+ const event = input.ownerDocument.createEvent('Event');
773
+ event.initEvent(type, true, true);
774
+ input.dispatchEvent(event);
775
+ }
750
776
  return true;
751
777
  }, password);
752
778
  if (!filled) return false;
753
- return page.evaluate(() => {
779
+ return evaluateViaCdp(page, () => {
754
780
  const button =
755
781
  document.querySelector('[data-testid="unlock-submit"]') ??
756
782
  document.querySelector('button[type="submit"]') ??
@@ -781,8 +807,8 @@ async function unlockIfNeeded(page, password) {
781
807
  }
782
808
 
783
809
  async function readLiveAccounts(page) {
784
- const raw = await page.evaluate(() => {
785
- const metamask = (window.stateHooks?.store?.getState?.() || {}).metamask || {};
810
+ const raw = await evaluateViaCdp(page, async () => {
811
+ const metamask = (await window.stateHooks?.getCleanAppState?.())?.metamask || {};
786
812
  const accts = metamask.internalAccounts || {};
787
813
  const byId = accts.accounts || {};
788
814
  const selectedId = accts.selectedAccount || null;
@@ -905,9 +931,10 @@ async function applyAccountNames(page, expectedAccounts) {
905
931
  if (!account.name || !account.address.startsWith('0x')) {
906
932
  continue;
907
933
  }
908
- await page.evaluate(
934
+ await evaluateViaCdp(
935
+ page,
909
936
  async ({ address, name }) => {
910
- const metamask = window.stateHooks?.store?.getState?.()?.metamask || {};
937
+ const metamask = (await window.stateHooks?.getCleanAppState?.())?.metamask || {};
911
938
  const normalizedAddress = String(address || '').toLowerCase();
912
939
  const accountsById = metamask.internalAccounts?.accounts || {};
913
940
  const accountId = Object.keys(accountsById).find(
@@ -937,9 +964,11 @@ async function applySelectedAccount(page, expectedSelected) {
937
964
  if (!expectedSelected?.address) {
938
965
  return;
939
966
  }
940
- await page.evaluate(
967
+ await evaluateViaCdp(
968
+ page,
941
969
  async ({ address }) => {
942
- const accounts = window.stateHooks?.store?.getState?.()?.metamask?.internalAccounts?.accounts || {};
970
+ const accounts =
971
+ (await window.stateHooks?.getCleanAppState?.())?.metamask?.internalAccounts?.accounts || {};
943
972
  const accountId = Object.keys(accounts).find(
944
973
  (id) => String(accounts[id]?.address || '').toLowerCase() === String(address || '').toLowerCase(),
945
974
  );
@@ -1009,9 +1038,18 @@ async function seedCdp(args) {
1009
1038
  fs.writeFileSync(extensionIdFile, `${extensionId}\n`);
1010
1039
  }
1011
1040
 
1012
- await page.evaluate(async (state) => {
1013
- await chrome.storage.local.set(state);
1014
- }, versionedState);
1041
+ try {
1042
+ await evaluateViaCdp(page, async (state) => {
1043
+ await chrome.storage.local.set(state);
1044
+ }, versionedState);
1045
+ } catch (error) {
1046
+ if (!String(error?.message || '').includes('property "chrome" of globalThis is inaccessible under scuttling mode')) {
1047
+ throw error;
1048
+ }
1049
+ console.error(
1050
+ '[fixture] chrome.storage is scuttled in the page realm; validating the prefilled profile state instead.',
1051
+ );
1052
+ }
1015
1053
  await page.goto(`chrome-extension://${extensionId}/home.html`, {
1016
1054
  waitUntil: 'load',
1017
1055
  timeout: 30000,
@@ -17,8 +17,65 @@ if [ -z "$RUNNER_DIR" ]; then
17
17
  fi
18
18
  RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)"
19
19
 
20
+ PROTOCOL_ROOT="${METAMASK_RUNNER_PROTOCOL_ROOT:-${METAMASK_RUNNER_FARMSLOT_ROOT:-${FARMSLOT_ROOT:-}}}"
21
+ if [ -z "$PROTOCOL_ROOT" ] && [ -f "$RUNNER_DIR/.farmslot-root" ]; then
22
+ PROTOCOL_ROOT="$(cat "$RUNNER_DIR/.farmslot-root")"
23
+ fi
24
+
20
25
  deps_ready() {
21
- [ -f "$RUNNER_DIR/node_modules/@farmslot/recipe-harness/package.json" ]
26
+ node --input-type=module - "$RUNNER_DIR" "$PROTOCOL_ROOT" <<'NODE'
27
+ import fs from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+ import path from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+
32
+ const runnerDir = process.argv[2];
33
+ const overrideRootInput = process.argv[3];
34
+ const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, 'package.json'), 'utf8'));
35
+ let dependencyVersionSatisfies;
36
+ try {
37
+ const requireFromRunner = createRequire(path.join(runnerDir, 'package.json'));
38
+ const helperPath = requireFromRunner.resolve(
39
+ '@farmslot/recipe-harness/runtime/deps-readiness',
40
+ );
41
+ ({ dependencyVersionSatisfies } = await import(pathToFileURL(helperPath).href));
42
+ if (typeof dependencyVersionSatisfies !== 'function') process.exit(1);
43
+ } catch {
44
+ process.exit(1);
45
+ }
46
+ let overrideRoot = null;
47
+ if (overrideRootInput) {
48
+ try {
49
+ overrideRoot = fs.realpathSync(overrideRootInput);
50
+ } catch {
51
+ overrideRoot = null;
52
+ }
53
+ }
54
+ function isOverrideLink(packageDir) {
55
+ if (!overrideRoot) return false;
56
+ try {
57
+ if (!fs.lstatSync(packageDir).isSymbolicLink()) return false;
58
+ const resolved = fs.realpathSync(packageDir);
59
+ const relative = path.relative(overrideRoot, resolved);
60
+ return relative === '' || (
61
+ relative !== '..' &&
62
+ !relative.startsWith(`..${path.sep}`) &&
63
+ !path.isAbsolute(relative)
64
+ );
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+ const invalid = Object.entries(pkg.dependencies ?? {}).filter(([name, request]) => {
70
+ const packageDir = path.join(runnerDir, 'node_modules', ...name.split('/'));
71
+ const installedPath = path.join(packageDir, 'package.json');
72
+ if (!fs.existsSync(installedPath)) return true;
73
+ if (isOverrideLink(packageDir)) return false;
74
+ const installed = JSON.parse(fs.readFileSync(installedPath, 'utf8'));
75
+ return !dependencyVersionSatisfies(installed.version, request);
76
+ });
77
+ process.exit(invalid.length === 0 ? 0 : 1);
78
+ NODE
22
79
  }
23
80
 
24
81
  # Library actions import @deeeed/metamask-harness by package name; a source checkout
@@ -55,10 +112,6 @@ if [ ! -f "$RUNNER_DIR/package.json" ]; then
55
112
  exit 1
56
113
  fi
57
114
 
58
- PROTOCOL_ROOT="${METAMASK_RUNNER_PROTOCOL_ROOT:-${METAMASK_RUNNER_FARMSLOT_ROOT:-${FARMSLOT_ROOT:-}}}"
59
- if [ -z "$PROTOCOL_ROOT" ] && [ -f "$RUNNER_DIR/.farmslot-root" ]; then
60
- PROTOCOL_ROOT="$(cat "$RUNNER_DIR/.farmslot-root")"
61
- fi
62
115
  if [ -n "$PROTOCOL_ROOT" ] && [ -f "$RUNNER_DIR/scripts/link-local-farmslot.mjs" ]; then
63
116
  if [ -f "$PROTOCOL_ROOT/packages/recipe-harness/package.json" ]; then
64
117
  FARMSLOT_ROOT="$(cd "$PROTOCOL_ROOT" && pwd -P)" \
@@ -77,6 +130,12 @@ echo "mm-harness: installing runner dependencies via npm in $RUNNER_DIR" >&2
77
130
  npm install --ignore-scripts >&2
78
131
  )
79
132
 
133
+ # npm may replace the local protocol links with registry packages. Restore the
134
+ # explicit co-development override after the complete dependency tree exists.
135
+ if [ -n "$PROTOCOL_ROOT" ] && [ -f "$RUNNER_DIR/scripts/link-local-farmslot.mjs" ]; then
136
+ FARMSLOT_ROOT="$PROTOCOL_ROOT" node "$RUNNER_DIR/scripts/link-local-farmslot.mjs" >&2
137
+ fi
138
+
80
139
  if ! deps_ready; then
81
140
  echo "mm-harness: runner dependencies still missing after npm install: $RUNNER_DIR" >&2
82
141
  exit 1
package/bin/mm-harness CHANGED
@@ -128,20 +128,38 @@ node_can_run_source_typescript() {
128
128
  rm -rf "$probe_dir"
129
129
  }
130
130
 
131
- # In a source checkout dist wins when present make that visible, and warn when
132
- # the compiled entry is older than the sources it shadows (a stale dist silently
133
- # runs old code while you edit src/).
131
+ # Published installs use dist. A source checkout uses dist only while it is at
132
+ # least as new as every TypeScript source it would shadow.
133
+ USE_DIST=0
134
134
  if [ -f "$ENTRY_DIST" ] && [ -f "$ENTRY_TS" ]; then
135
+ case "$RUNNER_DIR/" in
136
+ */node_modules/*)
137
+ USE_DIST=1
138
+ export MM_HARNESS_RUN_MODE="dist"
139
+ ;;
140
+ *)
141
+ newer_source=""
142
+ while IFS= read -r newer_source; do
143
+ break
144
+ done < <(find "$RUNNER_DIR/src" -name '*.ts' -newer "$ENTRY_DIST" -print 2>/dev/null)
145
+ if [ -n "$newer_source" ]; then
146
+ export MM_HARNESS_RUN_MODE="src"
147
+ echo "mm-harness: dist/ is older than src/ — running the current source checkout." >&2
148
+ echo " Next: npm run build to refresh dist/" >&2
149
+ else
150
+ USE_DIST=1
151
+ export MM_HARNESS_RUN_MODE="dist"
152
+ fi
153
+ ;;
154
+ esac
155
+ elif [ -f "$ENTRY_DIST" ]; then
156
+ USE_DIST=1
135
157
  export MM_HARNESS_RUN_MODE="dist"
136
- if find "$RUNNER_DIR/src" -name '*.ts' -newer "$ENTRY_DIST" 2>/dev/null | head -1 | grep -q .; then
137
- echo "mm-harness: dist/ is OLDER than src/ — this run uses the stale compiled code." >&2
138
- echo " Next: npm run build (or: trash dist/ to run straight from src via tsx)" >&2
139
- fi
140
158
  elif [ -f "$ENTRY_TS" ]; then
141
159
  export MM_HARNESS_RUN_MODE="src"
142
160
  fi
143
161
 
144
- if [ -f "$ENTRY_DIST" ]; then
162
+ if [ "$USE_DIST" -eq 1 ]; then
145
163
  exec node "$ENTRY_DIST" "$@"
146
164
  fi
147
165
 
@@ -146,16 +146,7 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
146
146
  if (runtime.backgroundUnresponsive === true) {
147
147
  findings.push("Extension UI reports background connection unresponsive.");
148
148
  }
149
- if (runtime.hasSubmitRequest !== true) {
150
- findings.push("stateHooks.submitRequestToBackground is unavailable.");
151
- }
152
- if (runtime.hasStore !== true) {
153
- findings.push("stateHooks.store is unavailable.");
154
- }
155
- if (runtime.hasPerpsStreamManager !== true) {
156
- findings.push("stateHooks.getPerpsStreamManager is unavailable.");
157
- }
158
- if (runtime.backgroundProbeOk !== true) {
149
+ if (runtime.hasSubmitRequest === true && runtime.backgroundProbeOk !== true) {
159
150
  warnings.push(`Perps background read probe failed: ${runtime.backgroundProbeError ?? "unknown error"}.`);
160
151
  }
161
152
  const extensionId = safeExtensionId(target);
package/dist/adapters.js CHANGED
@@ -496,7 +496,7 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
496
496
  );
497
497
  }
498
498
  });
499
- return {
499
+ const transport = {
500
500
  async execute(action, node, context) {
501
501
  if (action === "ui.navigate") {
502
502
  const live = await runLiveFirst(
@@ -513,6 +513,10 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
513
513
  return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
514
514
  }
515
515
  };
516
+ if (base.observe) {
517
+ transport.observe = (refs, node, context) => base.observe(refs, node, context);
518
+ }
519
+ return transport;
516
520
  }
517
521
  function normalizeUiWaitNode(node) {
518
522
  if (node.expected !== void 0 || typeof node.visible !== "boolean") return node;
@@ -64,24 +64,29 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
64
64
  record: true,
65
65
  cdpPort: runtimeOptions.cdpPort
66
66
  }) : void 0;
67
+ let result;
67
68
  try {
68
- let result;
69
+ result = await runner.run(runRequest);
70
+ } catch (error) {
69
71
  try {
70
- result = await runner.run(runRequest);
71
- } finally {
72
- if (adapter === "mobile") {
73
- const { hideMobileHudOnTeardown } = await import("../adapters.js");
74
- await hideMobileHudOnTeardown(
75
- projectRoot,
76
- recipeRunEnv(adapter, runtimeOptions)
77
- );
78
- }
72
+ await stopRecipeRecording(recording);
73
+ } catch (recordingError) {
74
+ console.error(
75
+ `WARN: recipe and video recording both failed; preserving recipe failure: ${recordingError instanceof Error ? recordingError.message : String(recordingError)}`
76
+ );
79
77
  }
80
- await stopRecipeRecording(recording, result);
81
- return finishRunDiagnostics(diagnosticBaseline, result);
78
+ throw error;
82
79
  } finally {
83
- await stopRecipeRecording(recording);
80
+ if (adapter === "mobile") {
81
+ const { hideMobileHudOnTeardown } = await import("../adapters.js");
82
+ await hideMobileHudOnTeardown(
83
+ projectRoot,
84
+ recipeRunEnv(adapter, runtimeOptions)
85
+ );
86
+ }
84
87
  }
88
+ await stopRecipeRecording(recording, result);
89
+ return finishRunDiagnostics(diagnosticBaseline, result);
85
90
  } finally {
86
91
  restoreRuntimeEnvironment();
87
92
  }
@@ -89,6 +89,10 @@ async function handleRuntimeLaunch({ options }) {
89
89
  return 0;
90
90
  }
91
91
  const launchLogPath = path.join(artifactsDir, "launch", "logs", "launch.log");
92
+ const verifySummaryPath = path.join(artifactsDir, "verify", "summary.json");
93
+ const launchSucceeded = isRecord(summary) && isRecord(summary.launch) && summary.launch.exitCode === 0;
94
+ const failurePath = launchSucceeded && fs.existsSync(verifySummaryPath) ? verifySummaryPath : fs.existsSync(launchLogPath) ? launchLogPath : summaryPath;
95
+ const reason = launchSucceeded ? "runtime_verify_failed" : "runtime_launch_failed";
92
96
  const report = runtimeLaunchReport("fail", {
93
97
  adapter,
94
98
  target,
@@ -96,8 +100,9 @@ async function handleRuntimeLaunch({ options }) {
96
100
  artifactsDir,
97
101
  summaryPath: fs.existsSync(summaryPath) ? summaryPath : void 0,
98
102
  launchLogPath: fs.existsSync(launchLogPath) ? launchLogPath : void 0,
99
- reason: "runtime_launch_failed",
100
- fix: `Read ${fs.existsSync(launchLogPath) ? launchLogPath : summaryPath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch)}`,
103
+ verifySummaryPath: fs.existsSync(verifySummaryPath) ? verifySummaryPath : void 0,
104
+ reason,
105
+ fix: `Read ${failurePath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch)}`,
101
106
  command,
102
107
  exitCode: result.status ?? 1
103
108
  });
@@ -350,11 +350,13 @@ function liveAdapterProcessTimeoutMs(node) {
350
350
  const timeoutMs = Number(node.live_adapter_timeout_ms);
351
351
  return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 6e4;
352
352
  }
353
+ const settleMs = Number(node.settle_ms);
354
+ const settleAllowance = Number.isFinite(settleMs) && settleMs > 0 ? settleMs : 0;
353
355
  if (node.timeout_ms != null) {
354
356
  const timeoutMs = Number(node.timeout_ms);
355
- return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + 5e3 : 6e4;
357
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + settleAllowance + 5e3 : 6e4;
356
358
  }
357
- return 6e4;
359
+ return 6e4 + settleAllowance;
358
360
  }
359
361
  function commandForPrepared(projectRoot, platform) {
360
362
  if (platform !== "core") {
@@ -10,11 +10,8 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
10
10
  "metamask.perps.read_account",
11
11
  "metamask.perps.assert_positions",
12
12
  "metamask.perps.assert_orders",
13
- // Both only read the collector's JSONL and export/assert over it.
14
- // `metamask.analytics.start_capture` is deliberately NOT listed: it spawns a
15
- // detached HTTP listener, so it falls through to 'arbitrary-code' and needs
16
- // explicit approval. Classifying a process-spawning action as read-only to
17
- // save an approval prompt would defeat the point of this allowlist.
13
+ // Collector reads are host-only; starting capture remains arbitrary-code
14
+ // because it spawns an HTTP listener.
18
15
  "metamask.analytics.read_events",
19
16
  "metamask.analytics.assert_events"
20
17
  ]);
@@ -31,7 +28,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
31
28
  "metamask.perps.ensure_orders",
32
29
  "metamask.perps.start_state",
33
30
  "metamask.perps.teardown_state",
34
- // Flips MetaMetrics consent in the wallet, so app-mutation, not read-only.
31
+ // Consent changes wallet state and is therefore an app mutation.
35
32
  "metamask.analytics.set_consent"
36
33
  ]);
37
34
  const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([