@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
@@ -1,4 +1,5 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { createHash } from 'node:crypto';
2
3
  import { dataTestId, marketSymbol, normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
4
  import {
4
5
  assertTrustedExternalMutationTarget,
@@ -14,6 +15,7 @@ import {
14
15
  } from './mutation-receipt.mjs';
15
16
  import { readSnapshot } from './read_snapshot.mjs';
16
17
  import { readPerpsRuntimeStateExpression } from './state.mjs';
18
+ import { readWalletStateExpression } from '../wallet/state.mjs';
17
19
  import {
18
20
  orderIdentityMatches,
19
21
  parseOrderCard,
@@ -25,6 +27,8 @@ import {
25
27
  waitForStableGlobalPerpsHome,
26
28
  } from './visible-mutation-identity.mjs';
27
29
 
30
+ const ORDER_ENTRY = '[data-testid="parent-selector-perps-order-entry"], [data-testid="perps-order-entry-page"]';
31
+
28
32
  function sleep(ms) {
29
33
  return new Promise((resolve) => setTimeout(resolve, ms));
30
34
  }
@@ -117,6 +121,17 @@ export function positionSelectionState(input, positions) {
117
121
  return { hasMatching: selectedItems(input, positions).length > 0 };
118
122
  }
119
123
 
124
+ export function placementReceiptSelection(input) {
125
+ const reference = input.node?.placement_node;
126
+ if (reference == null) return {};
127
+ if (typeof reference !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_./:-]{0,255}$/u.test(reference)
128
+ || reference.split('/').some((part) => !part || part === '.' || part === '..')) {
129
+ throw new Error('placement_node must reference a placement node in the current recipe call.');
130
+ }
131
+ const current = String(input.context?.nodeId ?? '');
132
+ return { nodeId: current.slice(0, current.lastIndexOf('/') + 1) + reference };
133
+ }
134
+
120
135
  async function navigatePerps(input) {
121
136
  const selected = String(input.node?.target ?? input.node?.destination ?? (input.node?.market || input.node?.symbol ? 'market' : 'home')).toLowerCase();
122
137
  return withExtensionPage(input, async (page) => {
@@ -135,7 +150,7 @@ async function navigatePerps(input) {
135
150
  if (selected === 'trade' || selected === 'order') {
136
151
  const side = String(input.node?.side ?? 'long').toLowerCase();
137
152
  const navigation = await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=new`);
138
- await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
153
+ await page.waitForSelector(ORDER_ENTRY, { timeoutMs: 20000 });
139
154
  return { action: input.action, target: selected, market: symbol, side, navigation, proofPath: 'ui-navigation' };
140
155
  }
141
156
  throw new Error(`Unsupported extension Perps navigation target: ${selected}`);
@@ -144,18 +159,20 @@ async function navigatePerps(input) {
144
159
 
145
160
  export async function readPositions(input) {
146
161
  return withExtensionPage(input, async (page) => {
162
+ const network = await assertNetwork(page, input.node?.network);
147
163
  const state = await page.readPositions();
148
164
  if (!state.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot read live Perps positions.');
149
165
  const matching = selectedItems(input, state.positions);
150
- return { action: input.action, source: state.source ?? 'perps-stream-manager-cache', count: state.positions.length, matchingCount: matching.length, positions: matching.map(redactPosition) };
166
+ return { action: input.action, source: state.source ?? 'perps-stream-manager-cache', network, count: state.positions.length, matchingCount: matching.length, positions: matching.map(redactPosition) };
151
167
  });
152
168
  }
153
169
 
154
170
  export async function readOrders(input) {
155
171
  return withExtensionPage(input, async (page) => {
172
+ const network = await assertNetwork(page, input.node?.network);
156
173
  const state = await readOpenOrdersState(page);
157
174
  const matching = selectedItems(input, state.orders);
158
- return { action: input.action, source: state.source, count: state.orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
175
+ return { action: input.action, source: state.source, network, count: state.orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
159
176
  });
160
177
  }
161
178
 
@@ -313,8 +330,10 @@ export async function closeOrders(input) {
313
330
  const hasBackgroundRequest = await page.evaluate(
314
331
  `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
315
332
  );
316
- if (hasBackgroundRequest) return closeOrderThroughBackground(input, page, timeoutMs);
317
- if (!hasBackgroundRequest) {
333
+ if (hasBackgroundRequest && input.node?.placement_node == null) {
334
+ return closeOrderThroughBackground(input, page, timeoutMs);
335
+ }
336
+ if (!hasBackgroundRequest || input.node?.placement_node != null) {
318
337
  if (input.node?.confirm_live !== true) {
319
338
  throw new Error('metamask.perps.close_orders requires confirm_live=true for visible live cancellation.');
320
339
  }
@@ -352,6 +371,7 @@ export async function closeOrders(input) {
352
371
  input,
353
372
  priorLookupIdentity,
354
373
  'metamask.perps.place_order',
374
+ placementReceiptSelection(input),
355
375
  );
356
376
  const expectedOrder = {
357
377
  market: symbol,
@@ -414,7 +434,6 @@ export async function closeOrders(input) {
414
434
  let dispatchTarget;
415
435
  try {
416
436
  await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
417
- await requirePerpsTradingContext(input, page);
418
437
  assertTrustedExternalMutationTarget(input, page, identity);
419
438
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
420
439
  await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs });
@@ -435,6 +454,7 @@ export async function closeOrders(input) {
435
454
  const rect = element.getBoundingClientRect();
436
455
  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
437
456
  })()`);
457
+ await requirePerpsTradingContext(input, page);
438
458
  assertTrustedExternalMutationTarget(input, page, identity);
439
459
  } catch (error) {
440
460
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
@@ -888,7 +908,9 @@ async function observePolicyViolationCleanupCandidate(page, expected, receipt, t
888
908
  }
889
909
 
890
910
  async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs) {
891
- await page.navigateHash(`#/perps/market/${encodeURIComponent(expected.market)}`);
911
+ if (!expected.fromCurrentForm) {
912
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(expected.market)}`);
913
+ }
892
914
  await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
893
915
  const deadline = Date.now() + timeoutMs;
894
916
  let stableIdentity = null;
@@ -1085,12 +1107,28 @@ export async function requirePerpsTradingContext(input, page) {
1085
1107
  }
1086
1108
  const actualProvider = String(state.activeProvider || '').toLowerCase();
1087
1109
  const actualNetwork = state.isTestnet === true ? 'testnet' : 'mainnet';
1110
+ const trusted = input.context?.trustedMutation;
1111
+ if (trusted?.proofLane === 'source-dev') {
1112
+ if (state.providerKnown !== true || provider !== 'hyperliquid' || network !== 'testnet'
1113
+ || actualProvider !== 'hyperliquid' || actualNetwork !== 'testnet') {
1114
+ throw new Error('Source-dev funded mutations require independently verified Hyperliquid testnet.');
1115
+ }
1116
+ const wallet = await page.evaluate(readWalletStateExpression());
1117
+ const address = String(wallet?.selectedAccount?.address ?? '').toLowerCase();
1118
+ if (!/^0x[0-9a-f]{40}$/u.test(address)
1119
+ || createHash('sha256').update(address).digest('hex') !== trusted.selectedIdentitySha256) {
1120
+ throw new Error('Source-dev funded mutation selected account changed after its visible identity proof.');
1121
+ }
1122
+ }
1088
1123
  if (actualProvider !== provider || actualNetwork !== network) {
1089
1124
  throw new Error(
1090
1125
  `Perps mutation expected ${provider}/${network}, got ${actualProvider || 'unknown'}/${actualNetwork}.`,
1091
1126
  );
1092
1127
  }
1093
- return { provider, network };
1128
+ return {
1129
+ provider, network,
1130
+ ...(trusted?.proofLane === 'source-dev' ? { selectedIdentitySha256: trusted.selectedIdentitySha256 } : {}),
1131
+ };
1094
1132
  }
1095
1133
 
1096
1134
  export async function requireCurrentVisibleFundingBalance(input, page, timeoutMs, expectedBaseUnits) {
@@ -1137,7 +1175,7 @@ async function requireCurrentOrderEntryFundingBalance(input, page, expectedBaseU
1137
1175
  if (BigInt(observedBaseUnits) < BigInt(requirement.minBaseUnits)) {
1138
1176
  throw new Error('Current visible Perps balance is below the runner-trusted funding minimum.');
1139
1177
  }
1140
- if (observedBaseUnits !== expectedBaseUnits) {
1178
+ if (expectedBaseUnits != null && observedBaseUnits !== expectedBaseUnits) {
1141
1179
  throw new Error('Current visible Perps balance changed after receipt publication.');
1142
1180
  }
1143
1181
  return {
@@ -1149,6 +1187,23 @@ async function requireCurrentOrderEntryFundingBalance(input, page, expectedBaseU
1149
1187
  };
1150
1188
  }
1151
1189
 
1190
+ async function readPreparedOrderConfiguration(page) {
1191
+ return page.evaluate(`(() => {
1192
+ const value = (testId) => document.querySelector('[data-testid="' + testId + '"] input')?.value ?? null;
1193
+ const submit = document.querySelector('[data-testid="submit-order-button"]');
1194
+ const direction = String(submit?.innerText || '').match(/\\b(long|short)\\b/iu)?.[1]?.toLowerCase() ?? null;
1195
+ return {
1196
+ route: String(location.hash),
1197
+ direction,
1198
+ autoCloseEnabled: Boolean(document.querySelector('input[data-testid="auto-close-toggle"]')?.checked),
1199
+ takeProfitPrice: value('tp-price-input'),
1200
+ stopLossPrice: value('sl-price-input'),
1201
+ takeProfitPercent: value('tp-percent-input'),
1202
+ stopLossPercent: value('sl-percent-input'),
1203
+ };
1204
+ })()`);
1205
+ }
1206
+
1152
1207
  export async function placeOrder(input) {
1153
1208
  requirePlaceOrderInputs(input);
1154
1209
  const symbol = marketSymbol(input);
@@ -1159,6 +1214,10 @@ export async function placeOrder(input) {
1159
1214
  const orderType = resolveOrderType(input);
1160
1215
  const positionMode = String(input.node?.position_mode ?? 'new').toLowerCase();
1161
1216
  const submitAttempts = Number(input.node?.submit_attempts ?? 1);
1217
+ const fromCurrentForm = input.node?.from_current_form === true;
1218
+ if (fromCurrentForm && (positionMode !== 'new' || submitAttempts !== 1)) {
1219
+ throw new Error('Prepared-form placement requires position_mode=new and submit_attempts=1.');
1220
+ }
1162
1221
  const roundingToleranceBaseUnits = String(input.node?.identity_rounding_tolerance_base_units ?? '10000');
1163
1222
  if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(roundingToleranceBaseUnits)
1164
1223
  || BigInt(roundingToleranceBaseUnits) > 1_000_000n) {
@@ -1197,6 +1256,7 @@ export async function placeOrder(input) {
1197
1256
  throw new Error('Requested Perps order exposure exceeds the runner-trusted operation cap.');
1198
1257
  }
1199
1258
  const expectedVisibleIdentity = {
1259
+ fromCurrentForm,
1200
1260
  market: symbol,
1201
1261
  side,
1202
1262
  leverage,
@@ -1213,7 +1273,7 @@ export async function placeOrder(input) {
1213
1273
  const hasBackgroundRequest = await page.evaluate(
1214
1274
  `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1215
1275
  );
1216
- if (hasBackgroundRequest) {
1276
+ if (hasBackgroundRequest && !fromCurrentForm) {
1217
1277
  await requirePerpsTradingContext(input, page);
1218
1278
  const resolvedPrice = await readCurrentMarketPrice(
1219
1279
  page,
@@ -1243,7 +1303,9 @@ export async function placeOrder(input) {
1243
1303
  const existingReceipt = await readExternalMutationReceipt(input, identity);
1244
1304
  if (existingReceipt) {
1245
1305
  return withExternalMutationRecovery(existingReceipt, async () => {
1246
- await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
1306
+ if (!fromCurrentForm) {
1307
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
1308
+ }
1247
1309
  await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
1248
1310
  if (positionMode !== 'new') {
1249
1311
  throw new Error('resumed funded position modification requires manual visible inspection');
@@ -1320,21 +1382,35 @@ export async function placeOrder(input) {
1320
1382
  };
1321
1383
  });
1322
1384
  }
1323
- await page.navigateHash('#/perps-home');
1324
- const initialClean = await waitForStableGlobalPerpsHome(page, {
1325
- timeoutMs: Number(input.node?.timeout_ms ?? 45_000),
1326
- requireZero: true,
1327
- });
1328
- const fundingBalance = await requireCurrentVisibleFundingBalance(
1329
- input,
1330
- page,
1331
- Number(input.node?.timeout_ms ?? 45_000),
1332
- initialClean.balanceBaseUnits,
1333
- );
1385
+ let initialClean;
1386
+ let fundingBalance;
1387
+ if (fromCurrentForm) {
1388
+ await page.waitForSelector(ORDER_ENTRY, { timeoutMs: 20_000 });
1389
+ const [positions, orders] = await Promise.all([page.readPositions(), readOpenOrders(page)]);
1390
+ if (!positions.available || positions.positions.length !== 0 || orders.length !== 0) {
1391
+ throw new Error('Prepared-form placement requires an independently verified empty position/order baseline.');
1392
+ }
1393
+ initialClean = { positionCount: 0, orderCount: 0 };
1394
+ fundingBalance = await requireCurrentOrderEntryFundingBalance(input, page);
1395
+ } else {
1396
+ await page.navigateHash('#/perps-home');
1397
+ initialClean = await waitForStableGlobalPerpsHome(page, {
1398
+ timeoutMs: Number(input.node?.timeout_ms ?? 45_000),
1399
+ requireZero: true,
1400
+ });
1401
+ fundingBalance = await requireCurrentVisibleFundingBalance(
1402
+ input,
1403
+ page,
1404
+ Number(input.node?.timeout_ms ?? 45_000),
1405
+ initialClean.balanceBaseUnits,
1406
+ );
1407
+ }
1334
1408
  const tradingContext = await requirePerpsTradingContext(input, page);
1335
1409
  const balanceBeforeBaseUnits = fundingBalance.observedBaseUnits;
1336
- await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
1337
- await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
1410
+ if (!fromCurrentForm) {
1411
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
1412
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
1413
+ }
1338
1414
  const resolvedPrice = await readCurrentMarketPrice(
1339
1415
  page,
1340
1416
  symbol,
@@ -1343,19 +1419,21 @@ export async function placeOrder(input) {
1343
1419
  const limitPrice = orderType === 'limit'
1344
1420
  ? resolveLimitPrice(input, isBuy, resolvedPrice)
1345
1421
  : null;
1346
- const baselineOrderIds = orderType === 'limit'
1422
+ const baselineOrderIds = orderType === 'limit' && !fromCurrentForm
1347
1423
  ? (await readVisibleOrderCards(page, symbol)).map((order) => order.id)
1348
1424
  : [];
1349
1425
  const initialOpenOrderCount = baselineOrderIds.length;
1350
- await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=${positionMode}`);
1351
- await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
1426
+ if (!fromCurrentForm) {
1427
+ await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=${positionMode}`);
1428
+ await page.waitForSelector(ORDER_ENTRY, { timeoutMs: 20000 });
1429
+ }
1352
1430
  const lateBackgroundRequest = await page.evaluate(
1353
1431
  `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1354
1432
  );
1355
- if (lateBackgroundRequest) {
1433
+ if (lateBackgroundRequest && !fromCurrentForm) {
1356
1434
  throw new Error('Background Perps placement appeared after visible setup; refusing an ambiguous dispatch.');
1357
1435
  }
1358
- if (!lateBackgroundRequest) {
1436
+ if (!lateBackgroundRequest || fromCurrentForm) {
1359
1437
  if (input.node?.confirm_live !== true) {
1360
1438
  throw new Error('metamask.perps.place_order requires confirm_live=true for visible live submission.');
1361
1439
  }
@@ -1363,9 +1441,11 @@ export async function placeOrder(input) {
1363
1441
  if (submitAttempts !== 1) {
1364
1442
  throw new Error('Visible funded Perps submission requires submit_attempts=1.');
1365
1443
  }
1366
- await page.setInput(`${dataTestId('amount-input-field')} input`, amount);
1367
- await page.setInput(`${dataTestId('leverage-input')} input`, String(leverage));
1368
- if (orderType === 'limit') {
1444
+ if (!fromCurrentForm) {
1445
+ await page.setInput(`${dataTestId('amount-input-field')} input`, amount);
1446
+ await page.setInput(`${dataTestId('leverage-input')} input`, String(leverage));
1447
+ }
1448
+ if (orderType === 'limit' && !fromCurrentForm) {
1369
1449
  await page.click(dataTestId('order-type-limit'));
1370
1450
  const limitInput = `${dataTestId('limit-price-input')} input`;
1371
1451
  await page.waitForSelector(limitInput, { timeoutMs: 10000 });
@@ -1379,6 +1459,7 @@ export async function placeOrder(input) {
1379
1459
  const submit = document.querySelector('[data-testid="submit-order-button"]');
1380
1460
  return {
1381
1461
  amount: value('amount-input-field'),
1462
+ denomination: document.querySelector('[data-testid="amount-input-denomination-unit"]')?.textContent?.trim() ?? null,
1382
1463
  leverage: value('leverage-input'),
1383
1464
  limitPrice: limit,
1384
1465
  limitSelected: document.querySelector('[data-testid="order-type-limit"]')?.getAttribute('aria-pressed') === 'true',
@@ -1391,19 +1472,29 @@ export async function placeOrder(input) {
1391
1472
  if (Number(configured.amount) !== Number(amount) || Number(configured.leverage) !== leverage) {
1392
1473
  throw new Error('Visible Perps order inputs did not retain the requested values.');
1393
1474
  }
1475
+ if (configured.denomination !== null && configured.denomination !== 'USD') {
1476
+ throw new Error('Visible Perps amount must be denominated in USD before funded submission.');
1477
+ }
1394
1478
  if (orderType === 'limit' && (!configured.limitSelected || Number(configured.limitPrice) !== limitPrice)) {
1395
1479
  throw new Error('Visible Perps limit inputs did not retain the requested values.');
1396
1480
  }
1397
1481
  if (!configured.submitEnabled) throw new Error('Visible Perps order is not submittable.');
1482
+ const preparedConfiguration = fromCurrentForm ? await readPreparedOrderConfiguration(page) : null;
1483
+ if (preparedConfiguration && (preparedConfiguration.route.split('?')[0] !== `#/perps/trade/${encodeURIComponent(symbol)}`
1484
+ || preparedConfiguration.direction !== side)) {
1485
+ throw new Error('Prepared Perps form does not match the requested market and direction.');
1486
+ }
1398
1487
  const mutation = await runGuardedExternalMutation({
1399
1488
  input,
1400
1489
  identity,
1401
- publicationFacts: { balanceBeforeBaseUnits, baselineOrderIds, fundingBalance, tradingContext },
1490
+ publicationFacts: {
1491
+ balanceBeforeBaseUnits, baselineOrderIds, fundingBalance, tradingContext,
1492
+ ...(preparedConfiguration ? { preparedConfiguration } : {}),
1493
+ },
1402
1494
  dispatch: async () => {
1403
1495
  let dispatchTarget;
1404
1496
  try {
1405
1497
  await requireCurrentOrderEntryFundingBalance(input, page, fundingBalance.observedBaseUnits);
1406
- await requirePerpsTradingContext(input, page);
1407
1498
  const currentConfiguration = await page.evaluate(`(() => {
1408
1499
  const value = (testId) => document.querySelector('[data-testid="' + testId + '"] input')?.value ?? null;
1409
1500
  const limitSelected = document.querySelector('[data-testid="order-type-limit"]')?.getAttribute('aria-pressed') === 'true';
@@ -1415,20 +1506,26 @@ export async function placeOrder(input) {
1415
1506
  const rect = element.getBoundingClientRect();
1416
1507
  return {
1417
1508
  amount: value('amount-input-field'),
1509
+ denomination: document.querySelector('[data-testid="amount-input-denomination-unit"]')?.textContent?.trim() ?? null,
1418
1510
  leverage: value('leverage-input'),
1419
1511
  limitPrice: value('limit-price-input'),
1420
1512
  orderType: limitSelected ? 'limit' : 'market',
1421
1513
  target: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
1422
1514
  };
1423
1515
  })()`);
1424
- if (currentConfiguration.amount !== amount
1425
- || currentConfiguration.leverage !== String(leverage)
1516
+ if (parseUsdBaseUnits(currentConfiguration.amount) !== identity.amountBaseUnits
1517
+ || (currentConfiguration.denomination !== null && currentConfiguration.denomination !== 'USD')
1518
+ || Number(currentConfiguration.leverage) !== leverage
1426
1519
  || currentConfiguration.orderType !== orderType
1427
- || (orderType === 'limit' && currentConfiguration.limitPrice !== String(limitPrice))
1520
+ || (orderType === 'limit' && parseUsdBaseUnits(currentConfiguration.limitPrice) !== expectedVisibleIdentity.limitPriceBaseUnits)
1428
1521
  || (orderType === 'market' && currentConfiguration.limitPrice != null)) {
1429
1522
  throw new Error('Visible Perps order inputs changed before dispatch.');
1430
1523
  }
1524
+ if (preparedConfiguration && JSON.stringify(await readPreparedOrderConfiguration(page)) !== JSON.stringify(preparedConfiguration)) {
1525
+ throw new Error('Prepared Perps direction or protection changed before dispatch.');
1526
+ }
1431
1527
  dispatchTarget = currentConfiguration.target;
1528
+ await requirePerpsTradingContext(input, page);
1432
1529
  assertTrustedExternalMutationTarget(input, page, identity);
1433
1530
  } catch (error) {
1434
1531
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
@@ -1603,16 +1700,22 @@ async function ensureProvider(input, config) {
1603
1700
  });
1604
1701
  }
1605
1702
 
1703
+ async function assertNetwork(page, network) {
1704
+ if (!network) return { skipped: true };
1705
+ const before = await readPerpsRuntimeState(page);
1706
+ if (!before.available || !before.networkKnown) {
1707
+ throw new Error('Cannot verify the current Perps network.\nNext: mm-harness verify');
1708
+ }
1709
+ const actual = before.isTestnet ? 'testnet' : 'mainnet';
1710
+ if (actual !== network) {
1711
+ throw new Error(`Expected Perps network ${network}, got ${actual}.\nNext: select ${network} through the supported product UI, then rerun the recipe.`);
1712
+ }
1713
+ return { requested: network, isTestnet: before.isTestnet, changed: false };
1714
+ }
1715
+
1606
1716
  async function ensureNetwork(input, config) {
1607
1717
  if (!config.network) return { skipped: true };
1608
- const expectedTestnet = String(config.network).toLowerCase() === 'testnet';
1609
- return withExtensionPage(input, async (page) => {
1610
- const before = await readPerpsRuntimeState(page);
1611
- if (!before.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot enforce Perps network.');
1612
- if (!before.networkKnown) throw new Error('Unable to read the current Perps network from persisted controller state.');
1613
- if (Boolean(before.isTestnet) === expectedTestnet) return { requested: config.network, isTestnet: before.isTestnet, changed: false };
1614
- throw new Error(`Expected Perps network ${config.network}, got isTestnet=${before.isTestnet}. Automatic background network toggles are forbidden without a durable receipt; select the network through a supported visible action before running.`);
1615
- });
1718
+ return withExtensionPage(input, (page) => assertNetwork(page, config.network));
1616
1719
  }
1617
1720
 
1618
1721
  async function assertReadyToTrade(input, config) {
@@ -1678,12 +1781,14 @@ export async function teardownState(input) {
1678
1781
  orders: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
1679
1782
  };
1680
1783
  const config = mergeStateConfig(defaults, params);
1784
+ const network = await ensureNetwork(input, config);
1681
1785
  const orders = await applyOrdersState(input, config.orders);
1682
1786
  const positions = await applyPositionsState(input, config.positions);
1683
1787
  const navigation = config.page === false ? { skipped: true } : await applyStateNavigation(input, config);
1684
1788
  return {
1685
1789
  action: input.action,
1686
1790
  phase: 'teardown',
1791
+ network,
1687
1792
  market: config.market ?? config.symbol ?? null,
1688
1793
  orders,
1689
1794
  positions,
@@ -117,6 +117,14 @@ export async function readFundsConfirmation(page, node, mode, timeoutMs = 30_000
117
117
  if (observed?.title !== expectedTitle || observed?.amount === null) {
118
118
  throw new Error(`Perps ${mode} confirmation did not finish loading.`);
119
119
  }
120
+ if (node.require_preview_fields === true) {
121
+ const missing = ['transactionFee', 'estimatedTime', 'receive'].filter((field) => !observed[field]);
122
+ if (!observed.payToken && !observed.receiveToken) missing.push('payToken/receiveToken');
123
+ if (observed.loadingVisible) missing.push('loading not settled');
124
+ if (missing.length > 0) {
125
+ throw new Error(`Perps ${mode} preview is incomplete: ${missing.join(', ')}.\nNext: mm-harness logs --full`);
126
+ }
127
+ }
120
128
  if (node.require_no_horizontal_overflow === true && (
121
129
  observed.layout.horizontalOverflow || observed.layout.clippedTestIds.length > 0
122
130
  )) {
@@ -499,7 +499,8 @@ async function visibleActivityCards(page) {
499
499
  return {
500
500
  testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
501
501
  label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
502
- visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0,
502
+ visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0 &&
503
+ rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth,
503
504
  };
504
505
  })`);
505
506
  }
@@ -1,3 +1,5 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
1
3
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
4
  import {
3
5
  normalizeVisiblePerpsState,
@@ -38,7 +40,7 @@ export async function readVisiblePerpsState(page, node = {}) {
38
40
  const raw = await page.evaluate(`(() => {
39
41
  const includeOffscreen = ${JSON.stringify(options.includeOffscreen)};
40
42
  const maximum = ${JSON.stringify(options.maxItems)};
41
- const relevant = /^(?:perps-|explore-markets-|market-list-view|market-row-|filter-select-button|sort-dropdown-button|search-input|position-card-|order-card-|transaction-card-|bottom-nav-|back-button|direction-|order-type-|amount-|balance-percent-|leverage-|auto-close|submit-order-button|limit-price-|tp-|sl-|custom-amount-|pay-with-|confirm-footer-button|wallet-initiated-header-back-button|transaction-fee-|bridge-(?:fee|time)-|receive-(?:row|value))/u;
43
+ const relevant = /^(?:perps-|parent-selector-perps-(?:market-list|order-entry)|explore-markets-|market-list-view|market-row-|filter-select-button|sort-dropdown-button|search-input|position-card-|order-card-|transaction-card-|bottom-nav-|back-button|direction-|order-type-|amount-|balance-percent-|leverage-|auto-close|submit-order-button|limit-price-|tp-|sl-|custom-amount-|pay-with-|confirm-footer-button|wallet-initiated-header-back-button|transaction-fee-|bridge-(?:fee|time)-|receive-(?:row|value))/u;
42
44
  const items = [];
43
45
  const offscreenItems = [];
44
46
  const clippedTestIds = [];
@@ -115,7 +117,7 @@ export async function readVisiblePerpsState(page, node = {}) {
115
117
  items,
116
118
  offscreenItems,
117
119
  truncated: matched > items.length + offscreenItems.length,
118
- orderEntry: document.querySelector('[data-testid="perps-order-entry-page"]') ? {
120
+ orderEntry: document.querySelector('[data-testid="parent-selector-perps-order-entry"], [data-testid="perps-order-entry-page"]') ? {
119
121
  symbol: field('perps-order-entry-asset-symbol')?.label || null,
120
122
  direction,
121
123
  orderType: field('order-type-limit')?.pressed === 'true' ? 'limit' : 'market',
@@ -150,7 +152,9 @@ export async function readVisiblePerpsState(page, node = {}) {
150
152
  };
151
153
  }
152
154
 
153
- runAdapter(async (input) => withExtensionPage(input, async (page) => ({
154
- action: input.action,
155
- ...await readVisiblePerpsState(page, input.node),
156
- })));
155
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
156
+ runAdapter(async (input) => withExtensionPage(input, async (page) => ({
157
+ action: input.action,
158
+ ...await readVisiblePerpsState(page, input.node),
159
+ })));
160
+ }
@@ -4,7 +4,12 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
4
  const query = String(input.node?.query ?? '').trim();
5
5
  const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
6
6
  if (!query) throw new Error('metamask.perps.search_markets requires a non-empty query.');
7
- await page.setInput(dataTestId('search-input'), query);
7
+ const searchInput = dataTestId('search-input');
8
+ if (!await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(searchInput)}))`)) {
9
+ await page.click(dataTestId('market-list-search-toggle'));
10
+ await page.waitForSelector(searchInput, { timeoutMs });
11
+ }
12
+ await page.setInput(searchInput, query);
8
13
  const deadline = Date.now() + timeoutMs;
9
14
  let marketIds = [];
10
15
  while (Date.now() <= deadline) {
@@ -18,7 +23,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
18
23
  .filter(Boolean)
19
24
  .slice(0, 100))()`);
20
25
  if (marketIds.length > 0) break;
21
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
26
+ await new Promise((resolve) => setTimeout(resolve, 200));
22
27
  }
23
28
  if (marketIds.length === 0) {
24
29
  throw new Error(`Perps market search returned no visible rows for ${JSON.stringify(query)}.`);
@@ -62,7 +62,7 @@ export async function selectActivityFilter(page, input, options = {}) {
62
62
  entries: cards.map((card) => card.label),
63
63
  loadedBoundary: visible(document.querySelector('[data-testid="perps-activity-page-skeleton"]'))
64
64
  ? null
65
- : visible(document.querySelector('[data-testid="perps-activity-page"]'))
65
+ : visible(document.querySelector('[data-testid="perps-activity-page"],[data-testid="parent-selector-perps-activity"]'))
66
66
  ? cards.length > 0 ? 'visible-cards' : 'loaded-empty'
67
67
  : null,
68
68
  empty: String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 2_000),