@deeeed/metamask-harness 0.38.1 → 0.40.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +27 -17
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/assets/open_details.mjs +30 -0
  20. package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
  21. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  22. package/library/actions/extension/networks/add_custom.mjs +4 -0
  23. package/library/actions/extension/networks/custom_network.mjs +48 -1
  24. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  25. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  26. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  27. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  28. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  29. package/library/actions/extension/perps/perps.mjs +1029 -363
  30. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  31. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  32. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  33. package/library/actions/extension/perps/search_markets.mjs +32 -0
  34. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  35. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  36. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  37. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  38. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  39. package/library/actions/extension/platform/cdp.mjs +88 -9
  40. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  41. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  42. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  43. package/library/actions/extension/ui/navigate.mjs +45 -5
  44. package/library/actions/extension/wallet/import.mjs +70 -29
  45. package/library/actions/extension/wallet/lock.mjs +62 -5
  46. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  47. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  48. package/library/actions/mobile/assets/open_details.mjs +43 -0
  49. package/library/actions/mobile/assets/read_details.mjs +96 -0
  50. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  51. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  52. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  53. package/library/actions/mobile/networks/network-management.mjs +132 -0
  54. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  55. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  56. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  57. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  58. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  59. package/library/actions/mobile/platform/native-session.mjs +59 -0
  60. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  61. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  62. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  63. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  64. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  65. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  66. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  67. package/library/actions/mobile/ui/native-navigation.mjs +120 -23
  68. package/library/actions/mobile/ui/navigate.mjs +13 -2
  69. package/library/actions/mobile/wallet/import.mjs +17 -10
  70. package/library/actions/mobile/wallet/lock.mjs +14 -0
  71. package/library/actions/mobile/wallet/native-ui.mjs +88 -15
  72. package/library/actions/shared/perps/visible-state.mjs +33 -11
  73. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  74. package/library/manifests/extension.action-manifest.json +1707 -1241
  75. package/library/manifests/mobile.action-manifest.json +308 -1
  76. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  77. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  78. package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
  79. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  80. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  81. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  82. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  83. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  84. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  85. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  86. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  87. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  88. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  89. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  90. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  91. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  92. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  93. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  94. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  95. package/library/recipes/wallet/import.recipe.json +4 -14
  96. package/package.json +1 -1
  97. package/library/actions/extension/assets/finish_send.mjs +0 -128
  98. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  99. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  100. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  101. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  102. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  103. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  104. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -9,6 +9,7 @@ import { build } from "esbuild";
9
9
  import { init, parse } from "es-module-lexer";
10
10
  import { resolveRequiredLocalProtocolRoot, runnerDir } from "./paths.js";
11
11
  import { corePnpNodeOptions } from "./commands/core-readiness.js";
12
+ import { serializeTrustedMutationContext } from "./funding-execution-context.js";
12
13
  function actionFileStem(action) {
13
14
  return String(action).replace(/[^a-zA-Z0-9._-]/g, "_");
14
15
  }
@@ -306,7 +307,8 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
306
307
  context: {
307
308
  nodeId: context.nodeId,
308
309
  projectRoot: context.projectRoot,
309
- artifactsDir: context.artifactsDir
310
+ artifactsDir: context.artifactsDir,
311
+ ...context.trustedMutation ? { trustedMutation: serializeTrustedMutationContext(context.trustedMutation) } : {}
310
312
  },
311
313
  outputPath
312
314
  };
@@ -1,14 +1,41 @@
1
1
  function isRecord(value) {
2
2
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
3
  }
4
- function validateMetaMaskActionInputs(recipe, adapter) {
4
+ function resolveMetaMaskParameterValue(value, params) {
5
+ if (typeof value === "string") {
6
+ const exact = /^\{\{params\.([A-Za-z0-9_.-]+)\}\}$/u.exec(value);
7
+ if (!exact) return value;
8
+ let current = params;
9
+ for (const segment of exact[1].split(".")) {
10
+ if (!isRecord(current) || !Object.hasOwn(current, segment)) return value;
11
+ current = current[segment];
12
+ }
13
+ return current;
14
+ }
15
+ if (Array.isArray(value)) return value.map((entry) => resolveMetaMaskParameterValue(entry, params));
16
+ if (!isRecord(value)) return value;
17
+ return Object.fromEntries(
18
+ Object.entries(value).map(([key, entry]) => [key, resolveMetaMaskParameterValue(entry, params)])
19
+ );
20
+ }
21
+ function validateMetaMaskActionInputs(recipe, adapter, params) {
5
22
  if (!isRecord(recipe) || !isRecord(recipe.workflow)) return [];
6
23
  const nodes = isRecord(recipe.workflow.nodes) ? recipe.workflow.nodes : void 0;
7
24
  if (!nodes) return [];
8
25
  const findings = [];
9
- for (const [nodeId, node] of Object.entries(nodes)) {
26
+ for (const [nodeId, rawNode] of Object.entries(nodes)) {
27
+ const node = params ? resolveMetaMaskParameterValue(rawNode, params) : rawNode;
10
28
  if (!isRecord(node)) continue;
11
29
  const action = node.action;
30
+ const maximum = (name, limit) => {
31
+ if (typeof node[name] !== "number" || node[name] <= limit) return;
32
+ findings.push({
33
+ severity: "error",
34
+ code: "recipe.invalid_param",
35
+ path: `workflow.nodes.${nodeId}.${name}`,
36
+ message: `${action} ${name} must not exceed ${limit}`
37
+ });
38
+ };
12
39
  if (action === "metamask.perps.read_snapshot" && adapter === "extension") {
13
40
  if (node.market !== void 0 && node.markets !== void 0) {
14
41
  findings.push({
@@ -54,9 +81,148 @@ function validateMetaMaskActionInputs(recipe, adapter) {
54
81
  message: `${action} timeout_ms must not exceed 60000`
55
82
  });
56
83
  }
84
+ if (isRecord(node.sampling)) {
85
+ if (typeof node.sampling.count === "number" && node.sampling.count > 10) {
86
+ findings.push({
87
+ severity: "error",
88
+ code: "recipe.invalid_param",
89
+ path: `workflow.nodes.${nodeId}.sampling.count`,
90
+ message: `${action} sampling.count must not exceed 10`
91
+ });
92
+ }
93
+ if (typeof node.sampling.interval_ms === "number" && node.sampling.interval_ms > 3e4) {
94
+ findings.push({
95
+ severity: "error",
96
+ code: "recipe.invalid_param",
97
+ path: `workflow.nodes.${nodeId}.sampling.interval_ms`,
98
+ message: `${action} sampling.interval_ms must not exceed 30000`
99
+ });
100
+ }
101
+ }
102
+ if (isRecord(node.expect)) {
103
+ if (typeof node.expect.minimum_changed_market_count === "number" && node.expect.minimum_changed_market_count > 100) {
104
+ findings.push({
105
+ severity: "error",
106
+ code: "recipe.invalid_param",
107
+ path: `workflow.nodes.${nodeId}.expect.minimum_changed_market_count`,
108
+ message: `${action} expect.minimum_changed_market_count must not exceed 100`
109
+ });
110
+ }
111
+ if (typeof node.expect.minimum_history_count === "number" && node.expect.minimum_history_count > 500) {
112
+ findings.push({
113
+ severity: "error",
114
+ code: "recipe.invalid_param",
115
+ path: `workflow.nodes.${nodeId}.expect.minimum_history_count`,
116
+ message: `${action} expect.minimum_history_count must not exceed 500`
117
+ });
118
+ }
119
+ }
120
+ }
121
+ if (action === "metamask.perps.assert_visible_consistency" && adapter === "extension") {
122
+ maximum("sample_count", 10);
123
+ maximum("sample_interval_ms", 3e4);
124
+ maximum("minimum_changed_count", 200);
125
+ maximum("minimum_recent_activity_count", 50);
126
+ maximum("timeout_ms", 12e4);
127
+ maximum("max_items", 200);
128
+ }
129
+ const fundedVisibleMutation = adapter === "extension" && (action === "metamask.perps.close_visible_position" || action === "metamask.perps.close_orders" || action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl" || action === "metamask.perps.place_order" && node.confirm_live === true);
130
+ if (fundedVisibleMutation) {
131
+ const required = (name, pattern, message) => {
132
+ if (typeof node[name] === "string" && pattern.test(node[name])) return;
133
+ findings.push({
134
+ severity: "error",
135
+ code: "recipe.invalid_param",
136
+ path: `workflow.nodes.${nodeId}.${name}`,
137
+ message
138
+ });
139
+ };
140
+ const decimal = action === "metamask.perps.close_visible_position" || action === "metamask.perps.update_position_tpsl" ? node.funding_amount : node.amount ?? node.notional;
141
+ const decimalText = String(decimal ?? "");
142
+ const match = /^([1-9][0-9]*)(?:\.([0-9]{1,6}))?$/u.exec(decimalText);
143
+ if (!match) {
144
+ findings.push({
145
+ severity: "error",
146
+ code: "recipe.invalid_param",
147
+ path: `workflow.nodes.${nodeId}.${action === "metamask.perps.close_visible_position" || action === "metamask.perps.update_position_tpsl" ? "funding_amount" : "amount"}`,
148
+ message: `${action} funded decimal amount must be positive plain decimal text with at most six places`
149
+ });
150
+ }
151
+ if (action === "metamask.perps.place_order" && node.submit_attempts !== void 0 && node.submit_attempts !== 1) {
152
+ findings.push({
153
+ severity: "error",
154
+ code: "recipe.invalid_param",
155
+ path: `workflow.nodes.${nodeId}.submit_attempts`,
156
+ message: `${action} funded visible submission requires submit_attempts=1`
157
+ });
158
+ }
159
+ if (action === "metamask.perps.close_visible_position" || action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl") {
160
+ required("funding_side", /^(?:long|short)$/u, `${action} requires the authorized position side`);
161
+ }
162
+ if (action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl") {
163
+ required(
164
+ "funding_position_size",
165
+ /^(?=[0-9.]*[1-9])[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u,
166
+ `${action} requires the exact visible position size`
167
+ );
168
+ }
169
+ if (action === "metamask.perps.close_visible_position" && node.cleanup_only === true) {
170
+ required(
171
+ "funding_position_size",
172
+ /^(?=[0-9.]*[1-9])[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u,
173
+ `${action} cleanup requires the exact visible position size`
174
+ );
175
+ if (node.amount != null) {
176
+ findings.push({
177
+ severity: "error",
178
+ code: "recipe.invalid_param",
179
+ path: `workflow.nodes.${nodeId}.amount`,
180
+ message: `${action} cleanup must close the full exact position`
181
+ });
182
+ }
183
+ }
184
+ if (action === "metamask.perps.edit_margin" && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(String(node.funding_amount ?? ""))) {
185
+ findings.push({
186
+ severity: "error",
187
+ code: "recipe.invalid_param",
188
+ path: `workflow.nodes.${nodeId}.funding_amount`,
189
+ message: `${action} requires the authorized position amount`
190
+ });
191
+ }
192
+ if (action === "metamask.perps.close_orders") {
193
+ required("side", /^(?:long|short)$/u, `${action} requires the exact order side`);
194
+ if (!/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(String(node.limit_price ?? ""))) {
195
+ findings.push({
196
+ severity: "error",
197
+ code: "recipe.invalid_param",
198
+ path: `workflow.nodes.${nodeId}.limit_price`,
199
+ message: `${action} requires an explicit positive limit_price for resumable cancellation`
200
+ });
201
+ }
202
+ }
203
+ if (action === "metamask.perps.place_order" && String(node.order_type ?? "market") === "limit" && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(String(node.limit_price ?? ""))) {
204
+ findings.push({
205
+ severity: "error",
206
+ code: "recipe.invalid_param",
207
+ path: `workflow.nodes.${nodeId}.limit_price`,
208
+ message: `${action} funded limit submission requires explicit limit_price; offset-only pricing is not resumable`
209
+ });
210
+ }
211
+ for (const name of ["identity_rounding_tolerance_base_units", "balance_reconciliation_tolerance_base_units"]) {
212
+ if (node[name] === void 0) continue;
213
+ const value = String(node[name]);
214
+ if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(value) || BigInt(value) > 1000000n) {
215
+ findings.push({
216
+ severity: "error",
217
+ code: "recipe.invalid_param",
218
+ path: `workflow.nodes.${nodeId}.${name}`,
219
+ message: `${action} ${name} must be an integer string from 0 through 1000000`
220
+ });
221
+ }
222
+ }
57
223
  }
58
224
  const state = typeof node.state === "string" ? node.state.toLowerCase() : void 0;
59
- const createsOrder = action === "metamask.perps.place_order" || (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
225
+ const createsOrder = action === "metamask.perps.place_order" || adapter !== "extension" && (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
60
226
  if (!createsOrder) continue;
61
227
  const requireOneOf = (names, message) => {
62
228
  const present = names.some((name) => {
@@ -87,5 +253,6 @@ function validateMetaMaskActionInputs(recipe, adapter) {
87
253
  return findings;
88
254
  }
89
255
  export {
256
+ resolveMetaMaskParameterValue,
90
257
  validateMetaMaskActionInputs
91
258
  };
@@ -234,11 +234,16 @@ Common overrides:
234
234
  --platform <ios|android> Mobile platform preference; use --adapter for the product adapter
235
235
  --target <path> Checkout path (default: cwd)
236
236
  --artifacts-dir <dir> Override evidence output (default: active task/artifacts, otherwise a checkout-local temp/recipe/runs/<run>)
237
+ --funding-request <absolute-file> Original consented mutation request for external-mutation actions
238
+ --funding-preflight <absolute-file> Eligible funding preflight bound to the request bytes
239
+ --funding-release-proof <absolute-file> Exact release proof independently bound to funding eligibility
240
+ --funding-balance-proof <absolute-file> Exact balance proof and contained visible evidence chain
237
241
  --library <name=path> Add/override a recipe-library source (repeatable)
238
242
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
239
243
  --json Machine-readable output
240
244
  --json-stream Line-flushed JSONL progress + terminal event
241
245
  --record-video=full-run Record a video of the run
246
+ --hud <auto|show|hide> Recipe HUD policy (default: auto)
242
247
 
243
248
  Advanced integrations:
244
249
  runtime: --cdp-port, --watcher-port/--metro-port, --runtime-dir, --slot,
@@ -250,7 +255,7 @@ Advanced integrations:
250
255
 
251
256
  Example:
252
257
  mm-harness run wallet.smoke --describe
253
- mm-harness run perps.clean-market-testnet market=BTC --plan
258
+ mm-harness run perps.clean-market-testnet market=BTC --plan --adapter mobile
254
259
  mm-harness run recipe.json market=ETH --adapter extension`
255
260
  },
256
261
  {
@@ -458,7 +463,8 @@ Example:
458
463
  --verify Launch then poll CDP/bridge until ready (absorbs the old \`live\`)
459
464
  --runway Post-launch runway check (mobile only; teaching error elsewhere)
460
465
  --watch Persistent webpack watcher then relaunch (extension only)
461
- --sidepanel | --fullscreen Extension display mode (default --fullscreen)
466
+ --surface <fullscreen|sidepanel> Extension display mode (default: fullscreen)
467
+ --sidepanel | --fullscreen Legacy aliases for --surface
462
468
  --url <dapp-url> Open a dapp beside the sidepanel (default: MetaMask test dapp)
463
469
  --heal <off|infra-only|auto> Healing policy (default auto); bounds always enforced
464
470
  --device <udid|name> Target simulator/device (env: IOS_SIMULATOR / ADB_SERIAL)
@@ -3,7 +3,6 @@ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
5
5
  "ui.locators",
6
- "ui.activate_and_observe",
7
6
  "metamask.wallet.fixture_status",
8
7
  "metamask.wallet.validate_import",
9
8
  "metamask.wallet.list_accounts",
@@ -20,21 +19,22 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
20
19
  "metamask.perps.read_orders",
21
20
  "metamask.perps.read_account",
22
21
  "metamask.perps.read_snapshot",
22
+ "metamask.perps.assert_visible_consistency",
23
23
  "metamask.perps.assert_positions",
24
24
  "metamask.perps.assert_orders",
25
+ "metamask.perps.ensure_positions",
26
+ "metamask.perps.ensure_orders",
25
27
  // Collector reads are host-only; starting capture remains arbitrary-code
26
28
  // because it spawns an HTTP listener.
27
29
  "metamask.analytics.read_events",
28
30
  "metamask.analytics.assert_events"
29
31
  ]);
30
32
  const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
31
- "ui.activate_and_observe",
32
33
  "metamask.assets.verify_sorting",
33
34
  "metamask.assets.set_token_visibility",
34
35
  "metamask.assets.import_custom_token",
35
36
  "metamask.assets.prepare_send",
36
37
  "metamask.assets.review_send",
37
- "metamask.assets.finish_send",
38
38
  "metamask.assets.set_custom_gas",
39
39
  "metamask.assets.select_network_scope",
40
40
  "metamask.wallet.lock",
@@ -61,8 +61,6 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
61
61
  "metamask.perps.edit_margin",
62
62
  "metamask.perps.edit_order",
63
63
  "metamask.perps.update_position_tpsl",
64
- "metamask.perps.ensure_positions",
65
- "metamask.perps.ensure_orders",
66
64
  "metamask.perps.clear_performance_caches",
67
65
  "metamask.perps.measure_homepage_visible",
68
66
  "metamask.perps.prepare_local_snapshot_endpoint",
@@ -70,14 +68,13 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
70
68
  "metamask.perps.select_activity_filter",
71
69
  "metamask.perps.select_market_filter",
72
70
  "metamask.perps.open_balance_action",
71
+ "metamask.perps.assert_visible_consistency",
73
72
  "metamask.perps.start_state",
74
73
  "metamask.perps.teardown_state",
75
74
  // Consent changes wallet state and is therefore an app mutation.
76
75
  "metamask.analytics.set_consent"
77
76
  ]);
78
77
  const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
79
- "ui.activate_and_observe",
80
- "metamask.assets.finish_send",
81
78
  "metamask.swap_bridge.submit_transaction",
82
79
  "metamask.perps.close_positions",
83
80
  "metamask.perps.close_orders",
@@ -85,11 +82,7 @@ const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
85
82
  "metamask.perps.close_visible_position",
86
83
  "metamask.perps.edit_margin",
87
84
  "metamask.perps.edit_order",
88
- "metamask.perps.update_position_tpsl",
89
- "metamask.perps.ensure_positions",
90
- "metamask.perps.ensure_orders",
91
- "metamask.perps.start_state",
92
- "metamask.perps.teardown_state"
85
+ "metamask.perps.update_position_tpsl"
93
86
  ]);
94
87
  function metaMaskActionExecutionCapabilities(action) {
95
88
  const capabilities = [];
package/dist/runner.js CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  mobileSourceFingerprint,
7
7
  recordMobileSourceBaseline
8
8
  } from "./adapters/mobile/source-freshness.js";
9
+ import { readAndroidNativeRuntimeIdentity } from "./adapters/mobile/release-artifact.js";
10
+ import { readMobileReleaseArtifactState } from "./adapters/mobile/release-artifact-state.js";
9
11
  import {
10
12
  createAndroidVideoRecorder,
11
13
  createIosSimulatorVideoRecorder
@@ -29,6 +31,10 @@ import {
29
31
  importRecipeHarnessRuntimeReactNativeBridge,
30
32
  runnerDir
31
33
  } from "./paths.js";
34
+ import {
35
+ selectedIdentityFromWalletImport,
36
+ trustedMutationForAction
37
+ } from "./funding-execution-context.js";
32
38
  const OFFICIAL_ACTIONS = new Set(OFFICIAL_RECIPE_ACTIONS);
33
39
  async function createMetaMaskMobileRunner(options = {}) {
34
40
  return createMetaMaskRunner(
@@ -89,7 +95,10 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
89
95
  ).filter(
90
96
  (entry) => declaredActions.has(entry.action) && !existing.has(entry.action) && !lifecycle.some((lifecycleEntry) => lifecycleEntry.action === entry.action)
91
97
  );
92
- const autoHudDisabled = options.autoHud === false || process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false";
98
+ const autoHudDisabled = options.autoHud === false || options.autoHud === void 0 && (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false");
99
+ const externalMutationActions = new Set(
100
+ Object.entries(actionManifest.actions).filter(([, declaration]) => Array.isArray(declaration.execution_capabilities) && declaration.execution_capabilities.includes("external-mutation")).map(([action]) => action)
101
+ );
93
102
  const logger = deduplicateLibraryResolutionLogs(
94
103
  options.quietStdout ? new console.Console(process.stderr) : console,
95
104
  options.suppressLibraryResolutionLogs
@@ -99,10 +108,18 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
99
108
  return createRecipeRunner({
100
109
  actionManifest,
101
110
  adapters: withActionProgress(
102
- [...core, ...ui, ...lifecycle, ...custom].map((entry) => ({
103
- ...entry,
104
- ...adapterSecurity(entry.action, actionSources, options.trustTaskActions)
105
- })),
111
+ withMobileNativeRuntimeIdentity(
112
+ adapter,
113
+ withTrustedMutationContext(
114
+ [...core, ...ui, ...lifecycle, ...custom].map((entry) => ({
115
+ ...entry,
116
+ ...adapterSecurity(entry.action, actionSources, options.trustTaskActions)
117
+ })),
118
+ options.trustedMutation,
119
+ externalMutationActions,
120
+ adapter
121
+ )
122
+ ),
106
123
  options.onActionEvent
107
124
  ),
108
125
  logger,
@@ -130,6 +147,83 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
130
147
  }
131
148
  });
132
149
  }
150
+ function withMobileNativeRuntimeIdentity(adapter, adapters, identity = {
151
+ state: readMobileReleaseArtifactState,
152
+ read: readAndroidNativeRuntimeIdentity
153
+ }) {
154
+ if (adapter !== "mobile") return adapters;
155
+ return adapters.map((entry) => ({
156
+ ...entry,
157
+ async execute(node, context) {
158
+ const state = identity.state(context.projectRoot);
159
+ if (!state) {
160
+ if (context.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") {
161
+ throw new Error("Mobile release-artifact actions require current trusted runtime state. Next: rerun runtime-launch with the exact artifact.");
162
+ }
163
+ return entry.execute(node, context);
164
+ }
165
+ identity.read(state);
166
+ const result = await entry.execute(node, context);
167
+ const nativeRuntimeIdentity = identity.read(state);
168
+ const output = result.output && typeof result.output === "object" && !Array.isArray(result.output) ? result.output : { result: result.output };
169
+ return {
170
+ ...result,
171
+ output: {
172
+ ...output,
173
+ nativeRuntimeIdentity,
174
+ runtimeSessionId: nativeRuntimeIdentity.runtimeSessionId
175
+ }
176
+ };
177
+ }
178
+ }));
179
+ }
180
+ function withTrustedMutationContext(adapters, base, externalMutationActions, adapter = "extension") {
181
+ if (adapter !== "extension") return adapters;
182
+ let selectedIdentitySha256;
183
+ const identityProofAction = "metamask.wallet.import";
184
+ const identityInvalidatingActions = /* @__PURE__ */ new Set([
185
+ "metamask.wallet.ensure_unlocked",
186
+ "metamask.wallet.lock",
187
+ "metamask.wallet.reset",
188
+ "metamask.wallet.select_account",
189
+ "metamask.wallet.setup"
190
+ ]);
191
+ const identityPreservingUiActions = /* @__PURE__ */ new Set([
192
+ "ui.capture_surface",
193
+ "ui.locators",
194
+ "ui.screenshot",
195
+ "ui.wait_for"
196
+ ]);
197
+ return adapters.map((entry) => ({
198
+ ...entry,
199
+ async execute(node, context) {
200
+ const identityProof = entry.action === identityProofAction;
201
+ const invalidatesIdentity = identityInvalidatingActions.has(entry.action) || entry.action.startsWith("ui.") && !identityPreservingUiActions.has(entry.action);
202
+ if (identityProof || invalidatesIdentity) selectedIdentitySha256 = void 0;
203
+ const externalMutation = externalMutationActions.has(entry.action);
204
+ if (externalMutation && !base) {
205
+ throw new Error(
206
+ "External mutation requires runner-verified --funding-request and --funding-preflight files. Next: rerun with both funding files."
207
+ );
208
+ }
209
+ const trustedMutation = base && externalMutation ? await trustedMutationForAction(base, context.recipe, selectedIdentitySha256) : void 0;
210
+ const trustedContext = trustedMutation ? { ...context, trustedMutation } : context;
211
+ const result = await entry.execute(node, trustedContext).catch((error) => {
212
+ if (identityProof || invalidatesIdentity) selectedIdentitySha256 = void 0;
213
+ throw error;
214
+ });
215
+ if (invalidatesIdentity) selectedIdentitySha256 = void 0;
216
+ const selected = selectedIdentityFromWalletImport(entry.action, result, base);
217
+ if (selected) {
218
+ if (base && base.selectedIdentitySha256 !== selected) {
219
+ throw new Error("Same-run wallet identity changed after its trusted import proof.");
220
+ }
221
+ selectedIdentitySha256 = selected;
222
+ }
223
+ return result;
224
+ }
225
+ }));
226
+ }
133
227
  function deduplicateLibraryResolutionLogs(logger, suppress = false) {
134
228
  const seen = /* @__PURE__ */ new Set();
135
229
  return {
@@ -412,5 +506,7 @@ export {
412
506
  createMetaMaskRunner,
413
507
  isAutomaticHudProgress,
414
508
  mobileSourceAwareLifecycleAdapters,
415
- resolveMobileLifecycleReadinessTimeout
509
+ resolveMobileLifecycleReadinessTimeout,
510
+ withMobileNativeRuntimeIdentity,
511
+ withTrustedMutationContext
416
512
  };
package/docs/RECIPES.md CHANGED
@@ -60,7 +60,9 @@ mm-harness run recipe.json account="Account 2"
60
60
 
61
61
  Use the shared wallet setup recipe on either Mobile or Extension. `auto`
62
62
  reuses a ready fixture-backed profile and otherwise completes visible
63
- onboarding; `ui` requires a fresh onboarding state.
63
+ onboarding. On Extension, `ui` completes fresh visible onboarding or visibly
64
+ unlocks and proves/selects the exact derived account in an existing session.
65
+ On Mobile, `ui` requires fresh onboarding.
64
66
 
65
67
  ```bash
66
68
  mm-harness run wallet.import method=auto
@@ -74,6 +76,77 @@ fixture. Secret values are redacted from commands and evidence. Visible imports
74
76
  default to MetaMetrics off and skip Mobile's optional interests; password terms
75
77
  are required and always accepted by the action.
76
78
 
79
+ ### Extension Perps release recipes
80
+
81
+ Discover these identifiers with `run --list --adapter extension --json` and
82
+ inspect the exact graph with `run <id> --describe --adapter extension --json`:
83
+
84
+ | Behavior | Canonical recipe |
85
+ | --- | --- |
86
+ | Activity filter routes | `perps.release-activity-filters` |
87
+ | Deposit and withdraw previews without submission | `perps.release-funds-flows` |
88
+ | Resting limit placement and exact cancellation | `perps.release-live-limit-order` |
89
+ | Filled market placement and exact position close | `perps.release-live-market-order` |
90
+ | Market header and statistics | `perps.release-market-details` |
91
+ | Market categories | `perps.release-market-filters` |
92
+ | Market text search | `perps.release-market-search` |
93
+ | Long/Short and Market/Limit entry controls | `perps.release-order-entry` |
94
+ | Amount, leverage, keyboard, auto-close, and TP/SL validation | `perps.release-order-validation` |
95
+ | Official-build rendered market/activity consistency | `perps.release-visible-consistency` |
96
+ | Favorite persistence and cleanup | `perps.release-watchlist` |
97
+ | Hook-backed controller snapshot consistency | `perps.source-dev-snapshot-consistency` |
98
+
99
+ The source-development snapshot recipe is compatibility evidence only and must
100
+ not support an official artifact verdict. Activity and funds recipes are
101
+ Extension-scoped because their semantic actions are not shared release
102
+ capabilities. Keep ticket-specific orchestration task-local and compose these
103
+ identifiers with `call` instead of copying their graphs.
104
+
105
+ Order-entry and order-validation callers must supply a market with no existing
106
+ position; the product exposes Modify/Close instead of New Long/Short for an
107
+ already-open market. Source-development snapshot callers may opt into a
108
+ positive `minimum_changed_market_count`; the default does not require a price
109
+ tick, while typed-history and visible-activity agreement remain strict.
110
+
111
+ The two live recipes require explicit `confirm_live=true`. The trusted runner
112
+ must also verify the exact consented funding request, preflight, release proof,
113
+ and contained balance proof. Market placement declares the exact provider and
114
+ network; background placement and cancellation refuse a mismatch without
115
+ toggling it. Immediately before publication and dispatch, the action also reads
116
+ the current visible Perps Home balance and requires the runner-trusted minimum.
117
+ The release onboarding session proves artifact, wallet, and origin provenance;
118
+ the fresh balance trace identifies the execution session, so a legitimate
119
+ release-artifact relaunch does not claim false same-session continuity. The
120
+ runner revalidates the current artifact tree and owned CDP profile, persists the
121
+ pre-dispatch receipt, and finishes receipt-bound cleanup and reconciliation. A
122
+ known-not-dispatched receipt is terminal and requires fresh consent and a new
123
+ claim. Selecting or resetting an account, changing its lock/setup session, or
124
+ using a generic UI press invalidates the cached identity proof; a fresh visible
125
+ `wallet.import` proof is required before another external mutation. Extension
126
+ `ensure_positions`, `ensure_orders`, and `teardown_state` only observe and assert
127
+ state that already exists; their schemas reject creation and cleanup inputs.
128
+ Extension does not expose `start_state`, readiness claims, multi-position
129
+ background closure, or the mutating `runner.action-validation` recipe. Financial
130
+ transitions must use an explicitly funded leaf action.
131
+ The shared `perps.clean-market-testnet` convergence recipe is therefore Mobile-
132
+ only: Extension discovery omits it and a direct Extension plan fails statically.
133
+ Planning or calling a recipe never weakens those gates.
134
+
135
+ ### Extension submission boundary
136
+
137
+ Extension currently exposes read-only Assets and Swap/Bridge evidence plus send
138
+ preparation/review, quote, Max, and slippage actions. It does not expose
139
+ `metamask.assets.finish_send`, `metamask.swap_bridge.submit_transaction`, or
140
+ generic `ui.activate_and_observe`. Those operations remain unavailable until a
141
+ domain-specific funding policy and durable pre-dispatch receipt/recovery
142
+ contract exists. The four former Assets submit recipes are not shipped.
143
+
144
+ The shared live Swap recipes remain available to Mobile, whose manifest owns a
145
+ separate native submission action. Extension `run --list` omits those recipes,
146
+ and a direct Extension plan fails statically. Extension quote and visible-state
147
+ recipes remain discoverable and plan normally; generic pointer activation is
148
+ not a substitute for a funded financial leaf.
149
+
77
150
  To prove the full real-user recovery journey, delete the current wallet through
78
151
  visible client UI and import it again:
79
152
 
@@ -81,6 +81,7 @@ current manifests do not expose the required durable capability.
81
81
  | Order entry | Funded Perps balance, market/limit inputs, validation, preview, fill, and duplicate protection | Parameterized order placement plus visible order-entry and validation recipes | **PARTIAL**: live state and provider outcomes remain release-dependent |
82
82
  | Position management | Open position, close, margin, and TP/SL changes | Visible position actions, close, edit-margin, and TP/SL actions | **PARTIAL**: exact outcome depends on live account state |
83
83
  | Deposit and withdrawal | Wallet funds, Perps balance, confirmations, and resulting activity | Balance actions and confirmation reads with a funds-flow recipe | **PARTIAL**: funds and provider availability are external |
84
+ | Perps state helpers | Deterministic position/order creation or cleanup before and after proof | Mobile state helpers can converge supported testnet state; Extension only exposes observation/assertion helpers and no start/readiness or multi-position background-close claim | **PARTIAL**: Extension convergence is **MISSING** and `perps.clean-market-testnet` plus mutating runner validation are omitted there |
84
85
  | Snapshot assertions | Positions, orders, markets, history, and watchlist | Bounded redacted snapshot when the background hook exists; visible-state reads on untouched releases | **AVAILABLE** with runtime-specific observation paths |
85
86
  | Deterministic seeded provider state | Balance, positions, limit orders, TP/SL, history, and controlled REST/stream behavior | Existing setup fixtures do not reproduce the complete QA provider model inside an untouched release artifact | **MISSING** |
86
87
 
@@ -90,7 +91,7 @@ current manifests do not expose the required durable capability.
90
91
  | --- | --- | --- | --- |
91
92
  | Token list, prices, details, and sorting | Funded account with known holdings and price data | Visible token-list/details reads, network scope, sorting assertions, and bundled recipes | **AVAILABLE** for EVM token UI |
92
93
  | Token search/import/hide/persistence | Known contracts and a persistent profile | Custom-token import, visibility changes, restart-safe recipes, and custom-network composition | **AVAILABLE** for Extension EVM tokens |
93
- | Native/token send and gas | Funded owned accounts and live gas | Prepare, review, custom-gas, finish, and Max/cancel/send recipes | **PARTIAL**: terminal submission is gated by funds and cost policy |
94
+ | Native/token send and gas | Funded owned accounts and live gas | Extension can prepare and review a send and configure gas, but exposes no terminal send/cancel action or submit recipe | **MISSING** for terminal Extension send until domain-specific funding and durable receipt policy exists |
94
95
  | Aggregate Android holdings | Imported wallet with multi-chain assets | Native-accessibility token-list read on an untouched physical-device release | **AVAILABLE** |
95
96
  | Balance streaming and aggregation arithmetic | Triggered inbound update and expected cross-network totals | Visible snapshots exist; deterministic inbound trigger and tolerance assertions are not bundled | **PARTIAL** |
96
97
  | NFTs, DeFi, Solana send, and Bitcoin details | Indexed owned positions/assets and chain-specific transaction flows | Generic visible UI only | **MISSING** |
@@ -101,8 +102,8 @@ current manifests do not expose the required durable capability.
101
102
  | --- | --- | --- | --- |
102
103
  | Surface, asset, network, and amount selection | Supported source/destination pair | Exact asset/network selection, amount/Max input, and visible-state reads on Extension; exact native selection and amount input on Android | **AVAILABLE** |
103
104
  | Quote and slippage | Live quote services and supported route | Surface, quote, Max-quote, and custom-slippage recipes | **AVAILABLE** |
104
- | Submit and confirmation | Sufficient source balance and gas | Typed submit action with explicit confirmation policy | **PARTIAL**: live funds and cost policy decide whether submission is allowed |
105
- | Sequential multi-network journey | Balances delivered by preceding swaps and bridges | Individual steps are composable, but the complete live-funds journey remains task-local and resumable by its case inventory | **PARTIAL** |
105
+ | Submit and confirmation | Sufficient source balance and gas | Mobile retains its typed native submit action; Extension exposes no submission action or live-submit recipe | **MISSING** on Extension until domain-specific funding and durable receipt policy exists |
106
+ | Sequential multi-network journey | Balances delivered by preceding swaps and bridges | Extension selection, amount, quote, Max, and slippage evidence remains composable, but no Extension terminal submit step exists | **MISSING** for an Extension end-to-end live journey |
106
107
  | Activity, balance deltas, and completed bridge tracking | Confirmed transactions and indexed activity | Visible-state evidence exists; reusable terminal activity and arithmetic assertions are incomplete | **PARTIAL** |
107
108
 
108
109
  ### Networks
@@ -112,8 +113,8 @@ current manifests do not expose the required durable capability.
112
113
  | Add, select, and remove custom networks | Known RPC configuration and chain identity | Typed custom-network and Chainlist add, visible-state read, select, and remove actions | **AVAILABLE** on Extension |
113
114
  | Custom-network tokens and persistence | Known token contract and persistent profile | Network actions compose with Assets import/visibility/persistence recipes | **AVAILABLE** on Extension |
114
115
  | Balance stability and fiat visibility | Funded custom network and live price APIs | Visible-state reads and a bounded balance-stability recipe | **PARTIAL**: external price availability remains uncontrolled |
115
- | Send Max and activity | Funded custom network and recipient | Assets send actions cover the transaction path | **PARTIAL**: exact activity and balance-delta assertions remain task-local |
116
- | Bridge tracking and details | Completed supported bridge route | Swap/Bridge composition exists; reusable terminal tracking assertions are incomplete | **PARTIAL** |
116
+ | Send Max and activity | Funded custom network and recipient | Extension can prepare and review the send, but cannot terminally submit or cancel it | **MISSING** until Extension send has domain-specific funding and durable receipts |
117
+ | Bridge tracking and details | Completed supported bridge route | Read-only Swap/Bridge evidence exists, but Extension cannot create the terminal bridge transaction | **MISSING** for a complete Extension bridge journey |
117
118
  | Android multi-network visibility | Imported wallet with holdings on multiple chains | Native Assets read reports aggregate visible holdings without Metro or a development bridge | **AVAILABLE** |
118
119
 
119
120
  ## Platform scope
@@ -0,0 +1,30 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
+ const symbol = String(input.node?.symbol ?? '').trim().toUpperCase();
5
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
6
+ if (!/^[A-Z0-9._-]{2,20}$/u.test(symbol)) {
7
+ throw new Error('metamask.assets.open_details requires a valid token symbol.');
8
+ }
9
+ const selector = await page.evaluate(`(() => {
10
+ const symbol = ${JSON.stringify(symbol)};
11
+ for (const [index, row] of [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')].entries()) {
12
+ const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').trim();
13
+ const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9._-]{1,19})$/u)?.[1]?.toUpperCase();
14
+ if (observed !== symbol) continue;
15
+ row.setAttribute('data-mm-harness-asset-index', String(index));
16
+ return '[data-mm-harness-asset-index="' + index + '"]';
17
+ }
18
+ return null;
19
+ })()`);
20
+ if (!selector) throw new Error(`Visible asset ${symbol} was not found.`);
21
+ await page.click(selector);
22
+ const deadline = Date.now() + timeoutMs;
23
+ while (Date.now() <= deadline) {
24
+ if (String(await page.evaluate('location.hash')).startsWith('#/asset/')) {
25
+ return { action: input.action, symbol, proofPath: 'trusted-pointer-visible-ui' };
26
+ }
27
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
28
+ }
29
+ throw new Error(`Asset details for ${symbol} did not open.`);
30
+ }));