@deeeed/metamask-harness 0.47.4 → 0.48.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 (146) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -2
  3. package/adapters/extension/lib/macos-focus.cjs +2 -2
  4. package/adapters/extension/live.sh +6 -8
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +1 -0
  6. package/adapters/shared/ensure-runner-deps.sh +6 -0
  7. package/dist/adapters/extension/runtime-decision.js +18 -1
  8. package/dist/adapters/extension/runtime.js +5 -5
  9. package/dist/adapters/extension/surface.js +1 -0
  10. package/dist/app-lifecycle.js +3 -1
  11. package/dist/command-contract.js +2 -0
  12. package/dist/commands/call.js +1 -8
  13. package/dist/commands/device-target.js +12 -3
  14. package/dist/commands/doctor.js +7 -5
  15. package/dist/commands/fixtures.js +1 -1
  16. package/dist/commands/launch/index.js +10 -0
  17. package/dist/commands/manifest.js +3 -1
  18. package/dist/commands/mobile-device-view.js +13 -3
  19. package/dist/commands/parse-args.js +2 -0
  20. package/dist/commands/run-engine.js +29 -4
  21. package/dist/commands/run.js +1 -1
  22. package/dist/commands/runtime-launch.js +10 -1
  23. package/dist/commands/status.js +8 -4
  24. package/dist/devices.js +31 -9
  25. package/dist/execution-provenance.js +8 -7
  26. package/dist/funding-execution-context.js +43 -13
  27. package/dist/heal-bounds.js +3 -3
  28. package/dist/live-adapter-contract.js +4 -0
  29. package/dist/manifest.js +2 -13
  30. package/dist/mm-harness-cli.js +2 -1
  31. package/dist/runner.js +14 -3
  32. package/docs/RECIPES.md +133 -22
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +3 -2
  34. package/library/actions/core/perps/_controller.mjs +10 -0
  35. package/library/actions/core/perps/close_orders.mjs +13 -17
  36. package/library/actions/extension/assets/home-token-identity.mjs +30 -0
  37. package/library/actions/extension/assets/import_custom_token.mjs +61 -79
  38. package/library/actions/extension/assets/open_details.mjs +18 -7
  39. package/library/actions/extension/assets/read_details.mjs +61 -29
  40. package/library/actions/extension/assets/read_visible_state.mjs +4 -1
  41. package/library/actions/extension/assets/select_network_scope.mjs +16 -9
  42. package/library/actions/extension/assets/set_token_visibility.mjs +84 -17
  43. package/library/actions/extension/assets/verify_sorting.mjs +62 -14
  44. package/library/actions/extension/networks/add_custom.mjs +19 -17
  45. package/library/actions/extension/networks/custom_network.mjs +8 -3
  46. package/library/actions/extension/networks/read_visible_state.mjs +1 -0
  47. package/library/actions/extension/performance/_navigation-memory.mjs +35 -6
  48. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +3 -2
  49. package/library/actions/extension/performance/measure_detached_dom.mjs +3 -2
  50. package/library/actions/extension/performance/measure_navigation_memory.mjs +3 -2
  51. package/library/actions/extension/perps/assert_visible_consistency.mjs +9 -4
  52. package/library/actions/extension/perps/close_visible_position.mjs +3 -3
  53. package/library/actions/extension/perps/edit_margin.mjs +1 -1
  54. package/library/actions/extension/perps/open_balance_action.mjs +16 -7
  55. package/library/actions/extension/perps/perps.mjs +150 -45
  56. package/library/actions/extension/perps/read_funds_confirmation.mjs +8 -0
  57. package/library/actions/extension/perps/read_snapshot.mjs +2 -1
  58. package/library/actions/extension/perps/read_visible_state.mjs +10 -6
  59. package/library/actions/extension/perps/search_markets.mjs +7 -2
  60. package/library/actions/extension/perps/select_activity_filter.mjs +1 -1
  61. package/library/actions/extension/perps/select_market_filter.mjs +74 -45
  62. package/library/actions/extension/perps/set_market_favorite.mjs +6 -3
  63. package/library/actions/extension/perps/state.mjs +1 -0
  64. package/library/actions/extension/perps/update_position_tpsl.mjs +1 -1
  65. package/library/actions/extension/perps/visible-mutation-identity.mjs +4 -2
  66. package/library/actions/extension/platform/cdp.mjs +23 -142
  67. package/library/actions/extension/swap_bridge/quote-state.mjs +54 -0
  68. package/library/actions/extension/swap_bridge/read_visible_state.mjs +4 -1
  69. package/library/actions/extension/swap_bridge/select_assets.mjs +11 -1
  70. package/library/actions/extension/swap_bridge/set_amount.mjs +10 -8
  71. package/library/actions/extension/swap_bridge/set_max_amount.mjs +17 -18
  72. package/library/actions/extension/swap_bridge/set_slippage.mjs +0 -9
  73. package/library/actions/extension/ui/navigate.mjs +58 -14
  74. package/library/actions/extension/wallet/lock.mjs +2 -1
  75. package/library/actions/mobile/assets/import_custom_token.mjs +1 -0
  76. package/library/actions/mobile/assets/read_visible_state.mjs +3 -1
  77. package/library/actions/mobile/assets/set_token_visibility.mjs +13 -9
  78. package/library/actions/mobile/networks/network-management.mjs +22 -15
  79. package/library/actions/mobile/networks/remove_custom.mjs +6 -2
  80. package/library/actions/mobile/perps/perps.mjs +3 -0
  81. package/library/actions/mobile/perps/read-visible-state-loop.mjs +1 -0
  82. package/library/actions/mobile/perps/set_market_favorite.mjs +15 -19
  83. package/library/actions/mobile/platform/native-session.mjs +26 -17
  84. package/library/actions/mobile/platform/observe-ui.mjs +2 -2
  85. package/library/actions/mobile/ui/navigate.mjs +1 -1
  86. package/library/actions/shared/perps/visible-state.mjs +14 -3
  87. package/library/manifests/core.action-manifest.json +14 -2
  88. package/library/manifests/extension.action-manifest.json +49 -16
  89. package/library/manifests/mobile.action-manifest.json +8 -7
  90. package/library/recipes/core/perps/smoke.recipe.json +23 -0
  91. package/library/recipes/core/perps/snapshot.recipe.json +26 -1
  92. package/library/recipes/extension/assets/release-custom-network-token-persistence.recipe.json +25 -8
  93. package/library/recipes/extension/performance/navigation-memory-suite.recipe.json +219 -0
  94. package/library/recipes/extension/performance/navigation-memory.recipe.json +61 -156
  95. package/library/recipes/extension/wallet/smoke.recipe.json +17 -8
  96. package/library/recipes/mobile/app/lifecycle.android-smoke.recipe.json +23 -4
  97. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +31 -6
  98. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +5 -4
  99. package/library/recipes/mobile/wallet/smoke.recipe.json +17 -8
  100. package/library/recipes/{assets → shared/assets}/release-token-address-import.recipe.json +7 -0
  101. package/library/recipes/{assets → shared/assets}/release-token-details.recipe.json +2 -2
  102. package/library/recipes/shared/assets/release-token-hide-readd.recipe.json +91 -0
  103. package/library/recipes/{assets → shared/assets}/release-token-persistence.recipe.json +3 -0
  104. package/library/recipes/{assets → shared/assets}/release-token-search-import.recipe.json +4 -1
  105. package/library/recipes/{assets → shared/assets}/release-token-sorting.recipe.json +13 -2
  106. package/library/recipes/{swap-bridge → shared/swap-bridge}/release-quote.recipe.json +29 -3
  107. package/package.json +3 -3
  108. package/site/assets/help-recipes.json +63 -23
  109. package/library/recipes/assets/release-token-hide-readd.recipe.json +0 -57
  110. package/library/recipes/core/perps/clean-market-testnet.recipe.json +0 -44
  111. package/library/recipes/core/perps/order-lifecycle.recipe.json +0 -82
  112. package/library/recipes/core/perps/read-markets.recipe.json +0 -36
  113. package/library/recipes/core/perps/trading-lifecycle.recipe.json +0 -80
  114. package/library/recipes/extension/perps/release-activity-filters.recipe.json +0 -51
  115. package/library/recipes/extension/perps/release-funds-flows.recipe.json +0 -76
  116. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +0 -109
  117. package/library/recipes/extension/perps/release-market-filters.recipe.json +0 -97
  118. package/library/recipes/extension/perps/release-order-entry.recipe.json +0 -158
  119. package/library/recipes/extension/perps/release-order-validation.recipe.json +0 -259
  120. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +0 -47
  121. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +0 -76
  122. package/library/recipes/mobile/perps/chase-assert-running.recipe.json +0 -110
  123. package/library/recipes/mobile/perps/chase-place.recipe.json +0 -145
  124. package/library/recipes/mobile/perps/chase-terminate.recipe.json +0 -98
  125. package/library/recipes/mobile/perps/performance.recipe.json +0 -722
  126. package/library/recipes/mobile/perps/pro-order-setup.recipe.json +0 -111
  127. package/library/recipes/mobile/perps/pro-order-start-state.recipe.json +0 -53
  128. package/library/recipes/mobile/perps/scale-assert-orders.recipe.json +0 -126
  129. package/library/recipes/mobile/perps/scale-place.recipe.json +0 -186
  130. package/library/recipes/mobile/perps/twap-assert-active.recipe.json +0 -97
  131. package/library/recipes/mobile/perps/twap-place.recipe.json +0 -146
  132. package/library/recipes/mobile/runner/action-validation.recipe.json +0 -336
  133. package/library/recipes/perps/clean-market-testnet.recipe.json +0 -49
  134. package/library/recipes/perps/lifecycle.recipe.json +0 -144
  135. package/library/recipes/perps/release-live-market-order.recipe.json +0 -139
  136. package/library/recipes/perps/release-watchlist.recipe.json +0 -111
  137. package/library/recipes/swap-bridge/release-max-quote.recipe.json +0 -72
  138. /package/library/recipes/{assets → shared/assets}/release-token-list.recipe.json +0 -0
  139. /package/library/recipes/{perps → shared/perps}/release-market-details.recipe.json +0 -0
  140. /package/library/recipes/{perps → shared/perps}/release-market-search.recipe.json +0 -0
  141. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-custom-slippage.recipe.json +0 -0
  142. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-live-max-swap.recipe.json +0 -0
  143. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-live-swap.recipe.json +0 -0
  144. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-surface.recipe.json +0 -0
  145. /package/library/recipes/{wallet → shared/wallet}/import.recipe.json +0 -0
  146. /package/library/recipes/{wallet → shared/wallet}/reset-import.recipe.json +0 -0
package/docs/RECIPES.md CHANGED
@@ -32,7 +32,7 @@ task proof and may call other recipes.
32
32
  "paramsSchema": {
33
33
  "type": "object",
34
34
  "properties": {
35
- "account": { "type": "string", "default": "Account 1" }
35
+ "timeout_ms": { "type": "integer", "minimum": 1, "default": 15000 }
36
36
  },
37
37
  "additionalProperties": false
38
38
  },
@@ -41,6 +41,7 @@ task proof and may call other recipes.
41
41
  "nodes": {
42
42
  "unlock": {
43
43
  "action": "metamask.wallet.ensure_unlocked",
44
+ "timeout_ms": "{{params.timeout_ms}}",
44
45
  "intent": "Make the fixture-backed wallet ready for use",
45
46
  "next": "done"
46
47
  },
@@ -54,8 +55,8 @@ Defaults are applied before validation. Root values use `key=value`; a nested
54
55
  recipe call uses `action: "call"`, `ref`, and `params`.
55
56
 
56
57
  ```bash
57
- mm-harness run recipe.json account="Account 2" --plan
58
- mm-harness run recipe.json account="Account 2"
58
+ mm-harness run recipe.json timeout_ms=20000 --plan
59
+ mm-harness run recipe.json timeout_ms=20000
59
60
  ```
60
61
 
61
62
  Use the shared wallet setup recipe on either Mobile or Extension. `auto`
@@ -76,11 +77,40 @@ fixture. Secret values are redacted from commands and evidence. Visible imports
76
77
  default to MetaMetrics off and skip Mobile's optional interests; password terms
77
78
  are required and always accepted by the action.
78
79
 
80
+ ### Retained-memory measurements
81
+
82
+ Budget failures retain their samples and limits in a private JSON report linked
83
+ from the failed node's error. Inspect that report before attributing growth or
84
+ changing a budget; it is supporting evidence, not a passing measurement.
85
+
86
+ Use `performance.navigation-memory` for one screen in an unlocked Extension.
87
+ Choose its current route/marker, sample count, and growth bounds before running:
88
+
89
+ ```sh
90
+ mm-harness run performance.navigation-memory \
91
+ 'screen_hash=#/settings' screen_test_id=settings-tab-bar-grouped --hud hide
92
+ ```
93
+
94
+ `performance.navigation-memory-suite` retains the full eight-screen audit,
95
+ detached-DOM check, and idle-control comparison. A one-screen slope does not
96
+ prove those broader claims. Check the suite's reference markers against the
97
+ tested build before running it.
98
+
79
99
  ### Extension Perps release recipes
80
100
 
81
101
  Discover these identifiers with `run --list --adapter extension --json` and
82
102
  inspect the exact graph with `run <id> --describe --adapter extension --json`:
83
103
 
104
+ Market/activity filters, visible consistency, funds previews, order-form cases,
105
+ live order journeys, watchlist and source-dev snapshot consistency are owned by the
106
+ [Perps team library](https://github.com/MetaMask/experimental-metamask-recipe-perps).
107
+ Register the library checkout to discover and compose those unchanged names:
108
+
109
+ ```sh
110
+ export RECIPE_LIBRARY_PATH="perps=/path/to/experimental-metamask-recipe-perps"
111
+ mm-harness run perps.release-market-filters --describe --adapter extension --json
112
+ ```
113
+
84
114
  | Behavior | Canonical recipe |
85
115
  | --- | --- |
86
116
  | Activity filter routes | `perps.release-activity-filters` |
@@ -90,10 +120,10 @@ inspect the exact graph with `run <id> --describe --adapter extension --json`:
90
120
  | Market header and statistics | `perps.release-market-details` |
91
121
  | Market categories | `perps.release-market-filters` |
92
122
  | 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` |
123
+ | Long/Short and Market/Limit entry controls | `perps.release-order-entry case=configuration` |
124
+ | Amount, leverage, keyboard, auto-close, and TP/SL validation | `perps.release-order-entry case=validation` |
95
125
  | Official-build rendered market/activity consistency | `perps.release-visible-consistency` |
96
- | Favorite persistence and cleanup | `perps.release-watchlist` |
126
+ | Favorite add/remove persistence and observed-baseline restoration | `perps.release-watchlist` |
97
127
  | Hook-backed controller snapshot consistency | `perps.source-dev-snapshot-consistency` |
98
128
 
99
129
  The source-development snapshot recipe is compatibility evidence only and must
@@ -102,11 +132,22 @@ Extension-scoped because their semantic actions are not shared release
102
132
  capabilities. Keep ticket-specific orchestration task-local and compose these
103
133
  identifiers with `call` instead of copying their graphs.
104
134
 
105
- Order-entry and order-validation callers must supply a market with no existing
135
+ Watchlist proof requires a visible market-bound favorite baseline before any
136
+ toggle. Unknown is not false. Restoration is verified on the audited Extension
137
+ runtime; the audited Mobile reader cannot establish that baseline, so it must
138
+ stop before mutation rather than infer identity from unrelated market text.
139
+
140
+ The order-form recipe is in the registered Perps team library. Both cases require a market with no existing
106
141
  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
142
+ already-open market. Validation prices and leverage must match the selected
143
+ market's current rules; the examples are not universal defaults. Give each
144
+ composed case a distinct `capture_path`.
145
+ Source-development snapshot callers may opt into a
108
146
  positive `minimum_changed_market_count`; the default does not require a price
109
147
  tick, while typed-history and visible-activity agreement remain strict.
148
+ Use the snapshot recipe's `history` object to bound existing record dates and
149
+ `capture_path` to separate evidence when composing calls. Market sampling does
150
+ not repeatedly fetch history.
110
151
 
111
152
  The two live recipes require explicit `confirm_live=true`. The trusted runner
112
153
  must also verify the exact consented funding request, preflight, release proof,
@@ -126,15 +167,56 @@ using a generic UI press invalidates the cached identity proof; a fresh visible
126
167
  `ensure_positions`, `ensure_orders`, and `teardown_state` only observe and assert
127
168
  state that already exists; their schemas reject creation and cleanup inputs.
128
169
  Extension does not expose `start_state`, readiness claims, multi-position
129
- background closure, or the mutating `runner.action-validation` recipe. Financial
170
+ background closure. The former `runner.action-validation` is now a repository-only
171
+ maintainer exercise at `scripts/qa/mobile-action-validation.recipe.json`, outside
172
+ the installed catalog. It requires explicit disposable setup and reserved testnet
173
+ account/market inputs; it is not a full action audit or a normal task recipe. Financial
130
174
  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.
175
+ The Perps team library owns `perps.clean-market-testnet` for Mobile and Core,
176
+ not Extension. Register that library before discovery or execution.
177
+ They require explicit `account`, `market` and `exclusive_testnet_market=true`.
178
+ That confirmation does not reserve the market or authorize deleting someone
179
+ else's trades. Plan first and execute only with actual task ownership.
133
180
  Planning or calling a recipe never weakens those gates.
134
181
 
182
+ ### Mobile advanced-order setup
183
+
184
+ These recipes belong to the Perps team library. Register it once:
185
+
186
+ ```sh
187
+ export RECIPE_LIBRARY_PATH="perps=/path/to/experimental-metamask-recipe-perps"
188
+ ```
189
+
190
+ `perps.pro-order-setup` preserves existing orders, positions, and strategies by
191
+ default. Its Scale, Chase, and TWAP placement callers inherit that policy.
192
+ When a proof requires a clean testnet market, reserve the account/market and
193
+ compose explicit setup with `trading_state=clean_selected_testnet_market`.
194
+ This parameter never authorizes cleanup of another task's trades.
195
+
196
+ `perps.scale-assert-orders` checks the complete market order count before
197
+ capturing three visible rows. The generic `assert_output` action compares a
198
+ prior node's structured field, for example `$.matchingCount` with `eq` and
199
+ `{{params.orders}}`; it is not limited to command output. Count and visibility
200
+ still need task-owned submission IDs to prove a specific ladder.
201
+
202
+ ### Fixed and Max quotes
203
+
204
+ `swap-bridge.release-quote` uses one graph for both amount modes. It defaults to
205
+ a fixed 0.0001 ETH-to-WETH quote on Ethereum with a 60-second transition budget.
206
+ Max ignores `amount` and uses the selected asset's available balance. Neither
207
+ mode submits a transaction.
208
+
209
+ ```sh
210
+ mm-harness run swap-bridge.release-quote amount=0.0001
211
+ mm-harness run swap-bridge.release-quote amount_mode=max
212
+ ```
213
+
214
+ Set `capture_path` to a distinct filename when composing multiple quotes in
215
+ one run. Pair, chain, asset identity, and timeout inputs remain explicit options.
216
+
135
217
  ### Extension submission boundary
136
218
 
137
- Extension currently exposes read-only Assets and Swap/Bridge evidence plus send
219
+ Extension currently exposes non-submitting Assets and Swap/Bridge evidence plus send
138
220
  preparation/review, quote, Max, and slippage actions. It does not expose
139
221
  `metamask.assets.finish_send`, `metamask.swap_bridge.submit_transaction`, or
140
222
  generic `ui.activate_and_observe`. Those operations remain unavailable until a
@@ -178,12 +260,13 @@ as supporting stills.
178
260
 
179
261
  ### Perps loading lifecycle validation
180
262
 
181
- One platform-neutral recipe owns the reusable Perps lifecycle proof. The
182
- harness adapter supplies Android or iOS behavior; the graph and lifecycle
183
- parameters remain identical. Run it with an explicit device pin:
263
+ The Mobile loading recipe shares one graph across Android and iOS. It requires
264
+ the product's source-dev instrumentation; platform parity still needs live
265
+ validation. Register the Perps team library, then pin the device:
184
266
 
185
267
  ```bash
186
268
  mm-harness run perps.performance \
269
+ --library perps=/path/to/experimental-metamask-recipe-perps \
187
270
  --device <android-serial-or-ios-udid> \
188
271
  account=<fixture-account-name> \
189
272
  content_variant=trending \
@@ -196,10 +279,10 @@ The Mobile recipe HUD is shown by default, including during performance
196
279
  validation. Use `--hud hide` only when a specific opaque/release proof requires
197
280
  an unobstructed product surface; do not hide it merely to collect timings.
198
281
 
199
- Supported lifecycle values are `navigate_return`, `cold_disk_cache`,
200
- `cold_no_cache`, `background_short`, `background_reconnect`, `account_switch`,
201
- and `network_switch`. The shared graph uses an existing fixture position and
202
- never fabricates exchange state inside the measurement. It proves native
282
+ Declared lifecycle values are `navigate_return`, `cold_no_cache`,
283
+ `background_short`, `background_reconnect`, `account_switch`, and
284
+ `network_switch`. The graph requires an existing position or an account with
285
+ no positions or orders; it never trades to create either prerequisite. It checks native
203
286
  visible content with `ui.wait_for`, captures production `[PerpsLoadProof]` and
204
287
  WebSocket milestones, and leaves detailed TTC/DFD to the app's Sentry traces.
205
288
  It never relabels Metro, fixture, or process-start duration as TTC.
@@ -207,6 +290,12 @@ The recipe does not label a checkout as main or candidate; pin and record the
207
290
  exact checkout commit externally, then use captured source milestones to verify
208
291
  which data path actually ran.
209
292
 
293
+ Warm return and short resume require resident-content milestones. The other
294
+ lifecycles also require live positions, orders and account streams. Each group
295
+ shares one capture ending, with the lifecycle and content identity preserved.
296
+ Cold preparation clears Perps disk/controller caches and disconnects its
297
+ channels before capture; an explicit process restart begins the proof.
298
+
210
299
  Rules:
211
300
 
212
301
  - Validate with `--plan` before side effects.
@@ -237,8 +326,9 @@ Locators, labels, routes, and screen structure can legitimately change, so a
237
326
  failed recipe is not by itself evidence of either recipe drift or a product
238
327
  regression.
239
328
 
240
- 1. Reproduce the failing node and compare it with authoritative acceptance
241
- criteria, product contracts, or known-good evidence.
329
+ 1. Preserve the first failure and compare it with authoritative acceptance
330
+ criteria, product contracts, or known-good evidence. Reproduce only after
331
+ confirming the action is safe to repeat.
242
332
  2. Inspect the current product surface and resolved recipe source, then classify
243
333
  the cause as product regression, recipe drift, fixture/runtime drift, or
244
334
  harness defect.
@@ -268,6 +358,23 @@ If changing a value would violate the recipe's postcondition, keep it as a
268
358
  documented recipe invariant rather than a parameter. Add a shared action only
269
359
  when repeated direct access has a stable cross-task contract.
270
360
 
361
+ Core actions construct isolated controllers. Pass account and network into
362
+ each action; a separate initialization node cannot prepare the next process.
363
+ Skip setup that does no work, while retaining the independent read assertions.
364
+ Signing requires a non-empty fixture account name. Address-only inputs support
365
+ reads; they must not silently choose the default signing account.
366
+
367
+ The Perps team library's `perps.trading-lifecycle` combines the Core long market
368
+ and limit examples. The harness retains read-only `perps.smoke` and
369
+ `perps.snapshot` examples; trading policy belongs to the team library.
370
+ It requires an explicit fixture account, market, and confirmation of exclusive
371
+ testnet ownership. Initial assertions refuse non-flat state rather than clearing
372
+ it. Failure-path cleanup is guarded by completed initial assertions and remains
373
+ market-scoped, so it cannot safely share that account/market with another task.
374
+ The limit branch uses the original 30%-below-mid offset; it must prove the order
375
+ rests rather than assume it cannot fill. These trading paths still require live
376
+ validation with a reserved funded testnet account.
377
+
271
378
  The protocol is authoritative:
272
379
  <https://farmslot.io/docs/reference/recipe-protocol-v1>.
273
380
 
@@ -307,7 +414,11 @@ mm-harness run onboarding.smoke --plan
307
414
  Use `--library wallet=/path/to/library` for one command. The alias is the
308
415
  source name; without one, the directory name is used. Resolution follows the
309
416
  explicit library order, then bundled MetaMask. Adapter-specific variants are
310
- selected deterministically from `recipes/<adapter>/<domain>/`. Legacy
417
+ selected deterministically from `recipes/<adapter>/<domain>/`. Identical
418
+ cross-adapter graphs live once in `recipes/shared/<domain>/`; an adapter-specific
419
+ variant takes precedence. Both resolve as `<domain>.<recipe>`, without a platform
420
+ or `shared` prefix. Keep domains below `core`, `extension`, `mobile`, or `shared`.
421
+ Legacy
311
422
  `*.<adapter>.recipe.json` paths remain readable during migration, but new and
312
423
  migrated libraries use the adapter-first layout. A file cannot declare its
313
424
  adapter through both forms. Every run records the root recipe, exact resolved
@@ -22,8 +22,9 @@ and trusted SHA-256. mm-harness never discovers or downloads a release.
22
22
 
23
23
  For Extension, `runtime-launch` verifies the ZIP digest and manifest version,
24
24
  extracts it safely, records archive and tree provenance, copies the exact tree
25
- to an isolated runtime directory, resets only the selected Chrome profile, and
26
- launches without building or patching the checkout.
25
+ to an isolated runtime directory, preserves the selected Chrome profile, and
26
+ launches without building or patching the checkout. A profile reset requires
27
+ explicit `--reset-profile`; an ordinary launch does not apply a wallet fixture.
27
28
 
28
29
  For Android, `runtime-launch` requires one physical-device serial, verifies the
29
30
  local APK digest, package ID, version name, and version code, installs it, and
@@ -498,6 +498,16 @@ function registerSignerHandlers(rootMessenger, childMessenger, account) {
498
498
  * @returns { controller, projectRoot, network, accountAddress, signerAddress }.
499
499
  */
500
500
  export async function getCoreControllerWithSigner(input) {
501
+ const requestedName = input.node?.account_name ?? input.node?.account;
502
+ if (requestedName !== undefined && (
503
+ typeof requestedName !== 'string' || !requestedName.trim() ||
504
+ /^0x[0-9a-fA-F]{40}$/u.test(requestedName.trim())
505
+ )) {
506
+ throw new Error(
507
+ 'Core signing requires a non-empty fixture account name, not an address.\n' +
508
+ 'Next: pass account=<reserved-fixture-name>; use addresses only for read-only actions.',
509
+ );
510
+ }
501
511
  const base = await getCoreController(input);
502
512
  const { controller, messenger: childMessenger, rootMessenger, accountAddress } = base;
503
513
  if (!rootMessenger || !childMessenger) {
@@ -10,21 +10,14 @@ import {
10
10
  optionalParam,
11
11
  } from './_controller.mjs';
12
12
 
13
- // core Cancel selected live Perps open orders on HyperLiquid testnet by driving
14
- // the headless PerpsController.cancelOrders() through the full signing/provider
15
- // path (Slice 2). Mirrors close_positions.mjs: read open orders → cancel the
16
- // selected subset via the controller → verify the selected orders are gone.
17
- //
18
- // cancelOrders() resolves the concrete orders to cancel via the ACTIVE provider's
19
- // getOpenOrders() (PerpsController wires getOpenOrders: () => this.getOpenOrders()
20
- // into the cancel context) and then batch-cancels by symbol. We pass the selected
21
- // symbols; with mode=all (no explicit market) we cancel every open order.
13
+ // Snapshot selection into exact IDs so dispatch and postcondition checks cannot
14
+ // expand to opposite-side or newly created orders sharing a market symbol.
22
15
 
23
16
  function sleep(ms) {
24
17
  return new Promise((resolve) => setTimeout(resolve, ms));
25
18
  }
26
19
 
27
- async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutMs) {
20
+ async function waitForOrdersAbsent(controller, accountAddress, orderIds, timeoutMs) {
28
21
  const deadline = Date.now() + timeoutMs;
29
22
  let orders = [];
30
23
  for (;;) {
@@ -32,7 +25,7 @@ async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutM
32
25
  standalone: true,
33
26
  userAddress: accountAddress,
34
27
  });
35
- const remaining = orders.filter((order) => symbols.includes(symbolForItem(order)));
28
+ const remaining = orders.filter((order) => orderIds.includes(order.orderId));
36
29
  if (remaining.length === 0) return orders;
37
30
  if (Date.now() >= deadline) return orders;
38
31
  await sleep(1000);
@@ -79,20 +72,22 @@ export async function closeOrders(input) {
79
72
  };
80
73
  }
81
74
 
82
- // Cancel by the selected symbols. cancelOrders() filters the active provider's
83
- // open orders to these symbols and batch-cancels them on the exchange.
84
- const result = await controller.cancelOrders({ symbols });
75
+ const orderIds = matching.map((order) => order.orderId);
76
+ if (orderIds.some((id) => typeof id !== 'string' || !id.trim()) || new Set(orderIds).size !== orderIds.length) {
77
+ throw new Error('Selected Core orders require unique non-empty orderId values before cancellation.\nNext: mm-harness actions metamask.perps.read_orders --adapter core --json');
78
+ }
79
+ const result = await controller.cancelOrders({ orderIds });
85
80
  const failure = cancelOrdersFailure(result);
86
81
  if (failure !== undefined) {
87
82
  throw controllerRejection({
88
83
  action: 'core cancelOrders',
89
- detail: `${JSON.stringify(symbols)}: ${JSON.stringify(result)}`,
84
+ detail: `${JSON.stringify(orderIds)}: ${JSON.stringify(result)}`,
90
85
  code: failure,
91
86
  });
92
87
  }
93
88
 
94
- const after = await waitForOrdersAbsent(controller, accountAddress, symbols, timeoutMs);
95
- const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
89
+ const after = await waitForOrdersAbsent(controller, accountAddress, orderIds, timeoutMs);
90
+ const stillOpen = after.filter((order) => orderIds.includes(order.orderId));
96
91
  if (stillOpen.length > 0) {
97
92
  throw new Error(
98
93
  `Expected selected orders to cancel, but ${stillOpen.length} remain: ${JSON.stringify(result)}`,
@@ -109,6 +104,7 @@ export async function closeOrders(input) {
109
104
  successCount: result.successCount,
110
105
  failureCount: result.failureCount,
111
106
  symbols,
107
+ orderIds,
112
108
  results: result.results,
113
109
  orders: stillOpen.map(redactOrder),
114
110
  proofPath: 'perps-controller-cancelOrders',
@@ -0,0 +1,30 @@
1
+ import { openHome } from '../ui/navigate.mjs';
2
+
3
+ export async function verifyHomeIdentity(page, token, assetId, timeoutMs) {
4
+ try {
5
+ await page.clickPoint(token.point.x, token.point.y);
6
+ const deadline = Date.now() + timeoutMs;
7
+ let route;
8
+ while (Date.now() < deadline) {
9
+ const value = String(await page.evaluate('location.hash'));
10
+ if (value.startsWith('#/asset/')) {
11
+ route = value;
12
+ break;
13
+ }
14
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 150))');
15
+ }
16
+ if (!route) throw new Error('The selected Home token did not expose an asset-details route.');
17
+ const [, , chainId, address] = route.split('/');
18
+ if (!/^0x[0-9a-f]+$/iu.test(chainId ?? '') || !/^0x[0-9a-f]{40}$/iu.test(address ?? '')) {
19
+ throw new Error('The selected Home route does not expose an ERC-20 identity.');
20
+ }
21
+ const observed = `eip155:${BigInt(chainId).toString()}/erc20:${address.toLowerCase()}`;
22
+ if (observed !== assetId) {
23
+ throw new Error(`Home asset identity ${observed} does not match ${assetId}.\nNext: select a network filter with an unambiguous matching token before continuing.`);
24
+ }
25
+ return { assetId, route, verified: true };
26
+ } finally {
27
+ await openHome(page, timeoutMs);
28
+ delete token.point;
29
+ }
30
+ }
@@ -1,10 +1,13 @@
1
1
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { readWalletStateExpression } from '../wallet/state.mjs';
3
+ import { verifyHomeIdentity } from './home-token-identity.mjs';
2
4
 
3
5
  const OPTIONS = dataTestId('asset-list-control-bar-action-button');
4
6
  const MANAGE = dataTestId('manageTokens__button');
5
7
  const ADD = dataTestId('token-management-add-custom-token-button');
6
8
  const PAGE = dataTestId('custom-token-import-page');
7
9
  const NETWORK = dataTestId('network-selector');
10
+ const NETWORK_PICKER = dataTestId('custom-token-import-network-selector');
8
11
  const ADDRESS = dataTestId('custom-token-import-address-input');
9
12
  const SYMBOL = dataTestId('custom-token-import-symbol-input');
10
13
  const DECIMALS = dataTestId('custom-token-import-decimal-input');
@@ -33,96 +36,69 @@ async function waitFor(page, predicate, message, timeoutMs) {
33
36
  throw new Error(message);
34
37
  }
35
38
 
36
- async function visibleButton(page, label) {
37
- const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
38
- const found = await page.evaluate(`(() => {
39
- const label = ${JSON.stringify(label)};
40
- const marker = ${JSON.stringify(marker)};
41
- for (const element of document.querySelectorAll('button')) {
42
- const text = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
43
- const style = getComputedStyle(element);
44
- const rect = element.getBoundingClientRect();
45
- if (text === label && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) {
46
- element.setAttribute('data-mm-harness-target', marker);
47
- return true;
48
- }
49
- }
50
- return false;
51
- })()`);
52
- if (!found) throw new Error(`Visible button ${JSON.stringify(label)} was not found.`);
53
- return `[data-mm-harness-target="${marker}"]`;
54
- }
55
-
56
- async function networkOption(page, network, chainId) {
39
+ async function networkOption(page, chainId) {
57
40
  return page.evaluate(`(() => {
58
- const network = ${JSON.stringify(network.toLowerCase())};
41
+ const picker = document.querySelector(${JSON.stringify(NETWORK_PICKER)});
42
+ if (!picker) return null;
59
43
  const chainIds = new Set(${JSON.stringify([
60
44
  String(chainId),
61
45
  `0x${chainId.toString(16)}`,
62
46
  `eip155:${chainId}`,
63
47
  ].map((value) => value.toLowerCase()))});
64
- for (const element of document.querySelectorAll('[data-testid^="network-list-item-"]')) {
48
+ for (const element of picker.querySelectorAll('[data-testid^="network-list-item-"]')) {
65
49
  const testId = String(element.getAttribute('data-testid') || '');
66
50
  const suffix = testId.slice('network-list-item-'.length).toLowerCase();
51
+ if (!chainIds.has(suffix)) continue;
52
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
53
+ const rect = element.getBoundingClientRect();
54
+ const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
55
+ if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
56
+ || rect.width <= 0 || rect.height <= 0 || !hit || !element.contains(hit)) continue;
67
57
  const label = String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
68
- if (chainIds.has(suffix) || label.toLowerCase().includes(network)) return { testId, label };
58
+ return { testId, label };
69
59
  }
70
60
  return null;
71
61
  })()`);
72
62
  }
73
63
 
74
- async function homeToken(page, symbol) {
64
+ async function homeToken(page, symbol, verifyIdentity = false) {
75
65
  return page.evaluate(`(() => {
76
66
  const symbol = ${JSON.stringify(symbol.toLowerCase())};
67
+ const verifyIdentity = ${JSON.stringify(verifyIdentity)};
68
+ const matches = [];
77
69
  for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
78
70
  const name = String(row.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
79
71
  const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
80
72
  const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1] || '';
81
- if (observed.toLowerCase() === symbol) return { name, symbol: observed.toUpperCase(), amount };
73
+ const style = getComputedStyle(row);
74
+ const rect = row.getBoundingClientRect();
75
+ if (observed.toLowerCase() !== symbol || style.display === 'none' || style.visibility === 'hidden'
76
+ || rect.width <= 0 || rect.height <= 0) continue;
77
+ matches.push({ row, name, symbol: observed.toUpperCase(), amount });
82
78
  }
83
- return null;
79
+ if (matches.length > 1) throw new Error('Visible token symbol matches multiple rows; identity is ambiguous.\\nNext: narrow the product network filter before importing.');
80
+ if (!matches.length) return null;
81
+ const { row, ...token } = matches[0];
82
+ if (verifyIdentity) {
83
+ row.scrollIntoView({ block: 'center', inline: 'nearest' });
84
+ const rect = row.getBoundingClientRect();
85
+ const x = rect.left + rect.width / 2;
86
+ const y = rect.top + rect.height / 2;
87
+ const hit = document.elementFromPoint(x, y);
88
+ if (!hit || !row.contains(hit)) throw new Error('The matching Home token is covered; identity cannot be verified.\\nNext: uncover the token row before importing.');
89
+ token.point = { x, y };
90
+ }
91
+ return token;
84
92
  })()`);
85
93
  }
86
94
 
87
- async function importedTokenIdentity(page, chainId, address, expectedSymbol) {
88
- return page.evaluate(`(async () => {
89
- const hooks = globalThis.stateHooks || {};
90
- const root = (await hooks.getCleanAppState?.()) || hooks.store?.getState?.() || {};
91
- const state = root.metamask || root;
92
- const allTokens = state.allTokens;
93
- const selectedAddress = String(state.selectedAddress || '').toLowerCase();
94
- if (!allTokens || typeof allTokens !== 'object' || !selectedAddress) {
95
- return { available: false, token: null };
96
- }
97
- const expectedChainId = ${JSON.stringify(chainId)};
98
- const chainEntry = Object.entries(allTokens).find(([key]) => {
99
- const normalized = String(key).toLowerCase();
100
- const value = normalized.startsWith('eip155:')
101
- ? Number(normalized.slice('eip155:'.length))
102
- : normalized.startsWith('0x')
103
- ? Number.parseInt(normalized.slice(2), 16)
104
- : Number(normalized);
105
- return value === expectedChainId;
106
- });
107
- const accountEntry = chainEntry && Object.entries(chainEntry[1] || {})
108
- .find(([account]) => String(account).toLowerCase() === selectedAddress);
109
- const tokens = Array.isArray(accountEntry?.[1]) ? accountEntry[1] : [];
110
- const expectedAddress = ${JSON.stringify(address.toLowerCase())};
111
- const expectedSymbol = ${JSON.stringify(expectedSymbol)};
112
- const token = tokens.find((entry) => {
113
- const tokenAddress = String(entry?.address || entry?.contractAddress || '').toLowerCase();
114
- return tokenAddress === expectedAddress && String(entry?.symbol || '').toUpperCase() === expectedSymbol;
115
- });
116
- return {
117
- available: true,
118
- token: token ? {
119
- address: String(token.address || token.contractAddress),
120
- symbol: String(token.symbol),
121
- decimals: token.decimals == null ? null : String(token.decimals),
122
- chainId: chainEntry?.[0] || null,
123
- } : null,
124
- };
125
- })()`, { awaitPromise: true });
95
+ async function selectedEvmAccount(page) {
96
+ const { selectedAccount } = await page.evaluate(readWalletStateExpression(), { awaitPromise: true });
97
+ if (!selectedAccount?.id || !['eip155:eoa', 'eip155:erc4337'].includes(selectedAccount.type)
98
+ || !/^0x[0-9a-f]{40}$/iu.test(selectedAccount.address ?? '')) {
99
+ throw new Error('The selected EVM account is unavailable; existing token identity cannot be verified.\nNext: select the intended EVM account before importing.');
100
+ }
101
+ return { id: selectedAccount.id, address: selectedAccount.address.toLowerCase(), type: selectedAccount.type };
126
102
  }
127
103
 
128
104
  runAdapter(async (input) => withExtensionPage(input, async (page) => {
@@ -136,39 +112,44 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
136
112
  throw new Error('metamask.assets.import_custom_token requires network, positive chain_id, ERC-20 address, and expected_symbol.');
137
113
  }
138
114
 
139
- const existingToken = await homeToken(page, expectedSymbol);
140
- const existingIdentity = existingToken
141
- ? await importedTokenIdentity(page, chainId, address, expectedSymbol)
142
- : null;
143
- if (existingToken && existingIdentity?.token) {
115
+ const account = await selectedEvmAccount(page);
116
+ const existingToken = await homeToken(page, expectedSymbol, true);
117
+ if (existingToken) {
118
+ const existingIdentity = await verifyHomeIdentity(
119
+ page, existingToken, `eip155:${chainId}/erc20:${address.toLowerCase()}`, timeoutMs,
120
+ );
121
+ const afterAccount = await selectedEvmAccount(page);
122
+ if (JSON.stringify(afterAccount) !== JSON.stringify(account)) {
123
+ throw new Error('The selected EVM account changed while checking the existing token.\nNext: keep the intended account selected for the full import check.');
124
+ }
144
125
  return {
145
126
  action: input.action,
146
127
  network,
147
128
  chainId,
148
129
  address,
149
130
  alreadyImported: true,
131
+ changed: false,
150
132
  token: existingToken,
151
- identity: existingIdentity.token,
133
+ identity: { ...existingIdentity, account, address, symbol: expectedSymbol, decimals: null, chainId: `0x${chainId.toString(16)}` },
152
134
  proofPath: 'trusted-pointer-visible-ui',
153
135
  };
154
136
  }
155
137
 
156
138
  if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
157
139
  await waitFor(page, () => visible(page, MANAGE), 'Manage tokens did not become visible.', timeoutMs);
158
- await page.click(await visibleButton(page, 'Manage tokens'));
140
+ await page.click(MANAGE);
159
141
  await waitFor(page, () => visible(page, ADD), 'Add a custom token did not become visible.', timeoutMs);
160
- await page.click(await visibleButton(page, 'Add a custom token'));
142
+ await page.click(ADD);
161
143
  await waitFor(page, () => visible(page, PAGE), 'Custom token import did not open.', timeoutMs);
162
144
  await page.click(NETWORK);
163
145
  const selectedNetwork = await waitFor(
164
146
  page,
165
- () => networkOption(page, network, chainId),
147
+ () => networkOption(page, chainId),
166
148
  `${network} (${chainId}) was not available in the custom token network selector.`,
167
149
  timeoutMs,
168
150
  );
169
- await page.click(dataTestId(selectedNetwork.testId));
170
- await waitFor(page, () => visible(page, ADDRESS), 'Custom token address input did not become visible.', timeoutMs);
171
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 2000))');
151
+ await page.click(`${NETWORK_PICKER} ${dataTestId(selectedNetwork.testId)}`);
152
+ await waitFor(page, async () => !(await visible(page, NETWORK_PICKER)) && await visible(page, ADDRESS), 'Custom token network selection did not return to the address form.', timeoutMs);
172
153
  await page.setInput(ADDRESS, address);
173
154
 
174
155
  let metadataSource = 'network';
@@ -183,7 +164,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
183
164
  return state.symbol && state.enabled ? state : null;
184
165
  }, 'Custom token metadata did not resolve to an enabled import.', Math.min(timeoutMs, 15_000));
185
166
  } catch (error) {
186
- if (expectedDecimals === null) throw error;
167
+ if (expectedDecimals === null || !(await visible(page, SYMBOL)) || !(await visible(page, DECIMALS))) throw error;
187
168
  metadataSource = 'manual';
188
169
  await page.setInput(SYMBOL, expectedSymbol);
189
170
  await page.setInput(DECIMALS, expectedDecimals);
@@ -216,6 +197,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
216
197
 
217
198
  return {
218
199
  action: input.action,
200
+ changed: true,
219
201
  network: selectedNetwork.label || network,
220
202
  chainId,
221
203
  address,