@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
@@ -0,0 +1,141 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ closeNativeSession,
4
+ createNativeSession,
5
+ nativeLabel,
6
+ nativeNode,
7
+ } from '../platform/native-session.mjs';
8
+
9
+ function visibleLabels(snapshot) {
10
+ return snapshot.nodes
11
+ .filter((node) => node.visibleToUser !== false)
12
+ .map(nativeLabel)
13
+ .filter(Boolean);
14
+ }
15
+
16
+ function quote(snapshot) {
17
+ const source = nativeNode(snapshot, 'source-token-area');
18
+ const destination = nativeNode(snapshot, 'dest-token-area');
19
+ const sourceAmount = nativeLabel(nativeNode(snapshot, 'source-token-area-input'));
20
+ const destinationAmount = nativeLabel(nativeNode(snapshot, 'dest-token-area-input'));
21
+ const button = nativeNode(snapshot, 'bridge-confirm-button');
22
+ const labels = visibleLabels(snapshot);
23
+ return {
24
+ source: nativeLabel(source),
25
+ destination: nativeLabel(destination),
26
+ sourceAmount,
27
+ destinationAmount,
28
+ buttonEnabled: button?.enabled === true,
29
+ warning: labels.find((label) => /insufficient|no quotes|unavailable|may fail|might fail/iu.test(label)) ?? null,
30
+ };
31
+ }
32
+
33
+ async function waitForQuote(session, expected, timeoutMs) {
34
+ const deadline = Date.now() + Math.min(timeoutMs, 60_000);
35
+ let stable = null;
36
+ let stableSince = 0;
37
+ while (Date.now() <= deadline) {
38
+ const snapshot = await session.snapshot();
39
+ const current = quote(snapshot);
40
+ const matches = current.source.toUpperCase().includes(expected.sourceSymbol) &&
41
+ current.destination.toUpperCase().includes(expected.destinationSymbol) &&
42
+ Number(current.sourceAmount) === expected.amount &&
43
+ Number(current.destinationAmount) > 0 &&
44
+ current.buttonEnabled &&
45
+ !current.warning;
46
+ const identity = JSON.stringify(current);
47
+ if (!matches) {
48
+ stable = null;
49
+ stableSince = 0;
50
+ } else if (stable !== identity) {
51
+ stable = identity;
52
+ stableSince = Date.now();
53
+ } else if (Date.now() - stableSince >= 1500) {
54
+ return current;
55
+ }
56
+ await new Promise((resolve) => setTimeout(resolve, 250));
57
+ }
58
+ throw new Error('The Mobile Swap quote did not remain submittable for the requested pair and amount.');
59
+ }
60
+
61
+ async function pressIfVisible(session, testId, timeoutMs) {
62
+ if (!nativeNode(await session.snapshot(), testId)) return false;
63
+ await session.execute('ui.press', { test_id: testId, timeout_ms: timeoutMs, settle: false });
64
+ return true;
65
+ }
66
+
67
+ async function waitForTerminalStatus(session, timeoutMs) {
68
+ const deadline = Date.now() + timeoutMs;
69
+ while (Date.now() <= deadline) {
70
+ const snapshot = await session.snapshot();
71
+ const labels = visibleLabels(snapshot);
72
+ const failed = labels.find((label) => /^(?:Swap|Bridge) failed$/iu.test(label));
73
+ if (failed) throw new Error(`Mobile ${failed}.`);
74
+ const success = labels.find((label) => /^(?:Swap|Bridge) complete$/iu.test(label));
75
+ if (success) return { snapshot, status: success };
76
+ if (nativeNode(snapshot, 'price-impact-footer-confirm')) {
77
+ await session.execute('ui.press', {
78
+ test_id: 'price-impact-footer-confirm',
79
+ timeout_ms: Math.max(1, deadline - Date.now()),
80
+ settle: false,
81
+ });
82
+ }
83
+ await new Promise((resolve) => setTimeout(resolve, 500));
84
+ }
85
+ throw new Error('Mobile Swap did not reach a terminal post-trade status.');
86
+ }
87
+
88
+ runAdapter(async (input) => {
89
+ const sourceSymbol = String(input.node?.source_symbol ?? '').trim().toUpperCase();
90
+ const destinationSymbol = String(input.node?.destination_symbol ?? '').trim().toUpperCase();
91
+ const amount = Number(input.node?.amount);
92
+ const timeoutMs = Number(input.node?.timeout_ms ?? 300_000);
93
+ if (!sourceSymbol || !destinationSymbol || !(amount > 0)) {
94
+ throw new Error('metamask.swap_bridge.submit_transaction requires source_symbol, destination_symbol, and positive amount.');
95
+ }
96
+ if (input.node?.confirm_live !== true) {
97
+ throw new Error('metamask.swap_bridge.submit_transaction requires confirm_live=true.');
98
+ }
99
+
100
+ const session = createNativeSession(input, 'Swap', `submit-${process.pid}`);
101
+ try {
102
+ await session.open();
103
+ const before = await waitForQuote(session, { sourceSymbol, destinationSymbol, amount }, timeoutMs);
104
+ await session.execute('ui.press', {
105
+ test_id: 'bridge-confirm-button',
106
+ timeout_ms: Math.min(timeoutMs, 60_000),
107
+ settle: false,
108
+ });
109
+ const terminal = await waitForTerminalStatus(session, timeoutMs);
110
+ await pressIfVisible(session, 'post-trade-bottom-sheet-view-activity-button', timeoutMs);
111
+ let activity = null;
112
+ const activityDeadline = Date.now() + Math.min(timeoutMs, 60_000);
113
+ while (Date.now() <= activityDeadline) {
114
+ const snapshot = await session.snapshot();
115
+ if (nativeNode(snapshot, 'activity-screen-safe-area-view')) {
116
+ const labels = visibleLabels(snapshot);
117
+ activity = labels.find((label) => {
118
+ const upper = label.toUpperCase();
119
+ return upper.includes(sourceSymbol) && upper.includes(destinationSymbol);
120
+ }) ?? labels.find((label) => /swap|bridge/iu.test(label)) ?? null;
121
+ if (activity) break;
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 300));
124
+ }
125
+ if (!activity) throw new Error('Confirmed Mobile Swap did not appear in visible Activity.');
126
+ return {
127
+ action: input.action,
128
+ sourceSymbol,
129
+ destinationSymbol,
130
+ amount,
131
+ destinationAmount: before.destinationAmount,
132
+ submitted: true,
133
+ confirmed: true,
134
+ status: terminal.status,
135
+ activity,
136
+ proofPath: 'native-accessibility-swap-post-trade-activity',
137
+ };
138
+ } finally {
139
+ await closeNativeSession(session);
140
+ }
141
+ });
@@ -1,6 +1,9 @@
1
1
  import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
2
  import { createAgentDeviceClient } from 'agent-device';
3
- import { nativeSessionName } from '../platform/native-session-name.mjs';
3
+ import {
4
+ nativeAgentDeviceStateDir,
5
+ nativeSessionName,
6
+ } from '../platform/native-session-name.mjs';
4
7
 
5
8
  const IDS = {
6
9
  wallet: ['wallet-safe-area', 'homepage-container'],
@@ -9,12 +12,18 @@ const IDS = {
9
12
  perpsEntry: 'homepage-balance-breakdown-row-perps',
10
13
  perpsTradeEntry: 'wallet-perps-action',
11
14
  swapTradeEntry: 'wallet-actions-bottom-sheet-swap-button',
15
+ swapBack: 'button-icon',
12
16
  swapSourceInput: 'source-token-area-input',
13
17
  swapSourceToken: 'source-token-area',
14
18
  swapDestinationToken: 'dest-token-area',
15
19
  perpsHome: 'perps-home',
16
20
  perpsHomeBack: 'perps-home-back-button',
21
+ perpsSearch: 'perps-home-search-toggle',
17
22
  tutorialSkip: 'perps-tutorial-skip-button',
23
+ modeLite: 'perps-mode-selection-lite-option',
24
+ modePro: 'perps-mode-selection-pro-option',
25
+ modeToggleLite: 'perps-mode-toggle-lite',
26
+ modeTogglePro: 'perps-mode-toggle-pro',
18
27
  market: 'perps-market-details-view',
19
28
  marketBack: 'perps-market-header-back-button',
20
29
  marketSubtitle: 'perps-market-header-subtitle',
@@ -23,6 +32,11 @@ const IDS = {
23
32
  proMarketList: 'perps-pro-market-header-market-list-button',
24
33
  proMarketSubtitle: 'perps-pro-market-header-subtitle',
25
34
  marketList: 'perps-market-list',
35
+ marketListClose: 'perps-market-list-close-button-back-button',
36
+ tokenList: 'token-list',
37
+ tokenListBack: 'back-button',
38
+ tokenDetails: 'token-asset-overview',
39
+ tokenDetailsBack: 'back-arrow-button',
26
40
  };
27
41
 
28
42
  export async function navigateOpaqueMobile(input, destination) {
@@ -33,8 +47,10 @@ export async function navigateOpaqueMobile(input, destination) {
33
47
  }
34
48
  const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
35
49
  const session = nativeSessionName(env, device, app);
50
+ const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
36
51
  const client = createAgentDeviceClient({
37
52
  session,
53
+ stateDir,
38
54
  lockPolicy: 'reject',
39
55
  lockPlatform: 'android',
40
56
  });
@@ -44,6 +60,7 @@ export async function navigateOpaqueMobile(input, destination) {
44
60
  device,
45
61
  app,
46
62
  session,
63
+ stateDir,
47
64
  client,
48
65
  });
49
66
  const context = {
@@ -52,9 +69,11 @@ export async function navigateOpaqueMobile(input, destination) {
52
69
  artifactsDir: input.context.artifactsDir,
53
70
  env,
54
71
  };
72
+ const perpsMode = destination.params?.mode === 'pro' ? 'pro' : 'lite';
55
73
  const press = (testId) => transport.execute('ui.press', {
56
74
  test_id: testId,
57
75
  timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
76
+ settle: false,
58
77
  }, context);
59
78
  const wait = (testId) => transport.execute('ui.wait_for', {
60
79
  test_id: testId,
@@ -80,7 +99,9 @@ export async function navigateOpaqueMobile(input, destination) {
80
99
  forceFull: true,
81
100
  });
82
101
  const identifiers = async () => new Set(
83
- (await snapshot()).nodes.map((node) => String(node.identifier ?? '')),
102
+ (await snapshot()).nodes
103
+ .filter((node) => node.visibleToUser !== false)
104
+ .map((node) => String(node.identifier ?? '')),
84
105
  );
85
106
 
86
107
  try {
@@ -90,26 +111,28 @@ export async function navigateOpaqueMobile(input, destination) {
90
111
  observedScreen = 'wallet-home';
91
112
  } else if (destination.page === 'swap') {
92
113
  observedScreen = await navigateSwap({ identifiers, press, wait, back });
114
+ } else if (destination.page === 'perps') {
115
+ observedScreen = await navigatePerpsHome({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000), perpsMode });
93
116
  } else if (destination.page === 'perps-market-list') {
94
- observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000) });
117
+ observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000), perpsMode });
95
118
  } else {
96
119
  const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
97
120
  const initialIds = await identifiers();
98
121
  observedScreen = destination.page === 'perps-market' && initialIds.has(IDS.marketList)
99
- ? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs })
100
- : await navigatePerps({ identifiers, press, wait, back, timeoutMs });
122
+ ? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode })
123
+ : await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
101
124
  if (destination.page === 'perps-market') {
102
125
  const market = String(destination.params.market.symbol).toUpperCase();
103
126
  const ids = await identifiers();
104
127
  if (ids.has(IDS.proMarket) && !await marketMatches(snapshot, market)) {
105
128
  await press(IDS.proMarketList);
106
- observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs });
129
+ observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode });
107
130
  } else if (!ids.has(IDS.proMarket) && (!ids.has(IDS.market) || !await marketMatches(snapshot, market))) {
108
131
  if (ids.has(IDS.market)) await press(IDS.marketBack);
109
132
  await press(`perps-market-row-item-${market}`);
110
133
  observedScreen = IDS.market;
111
134
  }
112
- await waitForPerpsDestination(identifiers, timeoutMs);
135
+ await waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
113
136
  if (!await marketMatches(snapshot, market)) {
114
137
  throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
115
138
  }
@@ -147,10 +170,10 @@ async function navigateSwap({ identifiers, press, wait, back }) {
147
170
  return IDS.swapSourceInput;
148
171
  }
149
172
 
150
- async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs }) {
173
+ async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode }) {
151
174
  const market = String(destination.params.market.symbol).toUpperCase();
152
175
  await press(`perps-market-row-item-${market}`);
153
- const observed = await waitForPerpsDestination(identifiers, timeoutMs);
176
+ const observed = await waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
154
177
  if (!await marketMatches(snapshot, market)) {
155
178
  throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
156
179
  }
@@ -162,32 +185,56 @@ async function navigateHome({ identifiers, press, wait, back }) {
162
185
  const ids = await identifiers();
163
186
  if (IDS.wallet.some((id) => ids.has(id))) return;
164
187
  if (ids.has(IDS.walletTab)) {
165
- await press(IDS.walletTab);
166
- await wait(IDS.wallet[0]);
167
- return;
188
+ if (await pressCurrent(press, IDS.walletTab)) {
189
+ await wait(IDS.wallet[0]);
190
+ return;
191
+ }
192
+ continue;
168
193
  }
169
194
  if (ids.has(IDS.marketBack)) {
170
- await press(IDS.marketBack);
195
+ await pressCurrent(press, IDS.marketBack);
171
196
  continue;
172
197
  }
173
198
  if (ids.has(IDS.proMarketBack)) {
174
- await press(IDS.proMarketBack);
199
+ await pressCurrent(press, IDS.proMarketBack);
175
200
  continue;
176
201
  }
177
202
  if (ids.has(IDS.perpsHomeBack)) {
178
- await press(IDS.perpsHomeBack);
203
+ await pressCurrent(press, IDS.perpsHomeBack);
179
204
  continue;
180
205
  }
181
- if (ids.has(IDS.perpsTradeEntry)) {
182
- await back();
206
+ if (ids.has(IDS.marketListClose)) {
207
+ await pressCurrent(press, IDS.marketListClose);
183
208
  continue;
184
209
  }
185
- throw new Error('Opaque Mobile navigation could not find a visible route back to wallet home.');
210
+ if (ids.has(IDS.tokenList) && ids.has(IDS.tokenListBack)) {
211
+ await pressCurrent(press, IDS.tokenListBack);
212
+ continue;
213
+ }
214
+ if (ids.has(IDS.tokenDetails) && ids.has(IDS.tokenDetailsBack)) {
215
+ await pressCurrent(press, IDS.tokenDetailsBack);
216
+ continue;
217
+ }
218
+ if (ids.has(IDS.swapSourceInput) && ids.has(IDS.swapBack)) {
219
+ await pressCurrent(press, IDS.swapBack);
220
+ continue;
221
+ }
222
+ await back();
186
223
  }
187
224
  throw new Error('Opaque Mobile navigation did not reach wallet home.');
188
225
  }
189
226
 
190
- async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
227
+ async function pressCurrent(press, testId) {
228
+ try {
229
+ await press(testId);
230
+ return true;
231
+ } catch (error) {
232
+ if (!/Selector did not match/iu.test(String(error?.message ?? error))) throw error;
233
+ return false;
234
+ }
235
+ }
236
+
237
+ async function navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
191
238
  let ids = await identifiers();
192
239
  if (ids.has(IDS.proMarket)) return IDS.proMarket;
193
240
  if (ids.has(IDS.market)) {
@@ -219,15 +266,61 @@ async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
219
266
  ids = await identifiers();
220
267
  }
221
268
  if (ids.has(IDS.tutorialSkip)) await press(IDS.tutorialSkip);
222
- return waitForPerpsDestination(identifiers, timeoutMs);
269
+ return waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
270
+ }
271
+
272
+ async function navigatePerpsHome({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
273
+ const deadline = Date.now() + timeoutMs;
274
+ let observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
275
+ while (Date.now() <= deadline) {
276
+ let ids = await identifiers();
277
+ if (ids.has(IDS.perpsHome)) return IDS.perpsHome;
278
+ if (perpsMode === 'lite' && ids.has(IDS.proMarket)) {
279
+ if (!ids.has(IDS.modeTogglePro)) {
280
+ throw new Error('Opaque Mobile navigation reached Pro market details without the visible Pro mode control.');
281
+ }
282
+ await press(IDS.modeTogglePro);
283
+ observed = await waitForPerpsMode(identifiers, press, deadline, 'lite');
284
+ ids = await identifiers();
285
+ }
286
+ if (ids.has(IDS.market)) {
287
+ await press(IDS.marketBack);
288
+ } else if (ids.has(IDS.marketList)) {
289
+ await press(IDS.marketListClose);
290
+ } else {
291
+ observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs: Math.max(1, deadline - Date.now()), perpsMode });
292
+ }
293
+ }
294
+ throw new Error(`Opaque Mobile navigation reached ${observed}, but did not converge to Perps Home.`);
223
295
  }
224
296
 
225
- async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs }) {
297
+ async function waitForPerpsMode(identifiers, press, deadline, perpsMode) {
298
+ let selected = false;
299
+ while (Date.now() <= deadline) {
300
+ const ids = await identifiers();
301
+ const expected = perpsMode === 'pro' ? IDS.proMarket : IDS.market;
302
+ if (ids.has(expected)) return expected;
303
+ const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
304
+ if (!selected && ids.has(option)) {
305
+ await press(option);
306
+ selected = true;
307
+ }
308
+ await new Promise((resolve) => setTimeout(resolve, 250));
309
+ }
310
+ throw new Error(`Opaque Mobile navigation did not visibly switch Perps to ${perpsMode} mode.`);
311
+ }
312
+
313
+ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
226
314
  let ids = await identifiers();
227
315
  if (ids.has(IDS.marketList)) return IDS.marketList;
228
- const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs });
316
+ const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
229
317
  ids = await identifiers();
230
318
  if (ids.has(IDS.marketList)) return IDS.marketList;
319
+ if (ids.has(IDS.perpsSearch)) {
320
+ await press(IDS.perpsSearch);
321
+ await wait(IDS.marketList);
322
+ return IDS.marketList;
323
+ }
231
324
  if (ids.has(IDS.proMarketList)) {
232
325
  await press(IDS.proMarketList);
233
326
  await wait(IDS.marketList);
@@ -236,10 +329,14 @@ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeout
236
329
  throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
237
330
  }
238
331
 
239
- async function waitForPerpsDestination(identifiers, timeoutMs) {
332
+ async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode = 'lite') {
240
333
  const deadline = Date.now() + timeoutMs;
241
334
  while (Date.now() <= deadline) {
242
335
  const ids = await identifiers();
336
+ if (ids.has(IDS.modeLite) || ids.has(IDS.modePro)) {
337
+ await press(perpsMode === 'pro' ? IDS.modePro : IDS.modeLite);
338
+ continue;
339
+ }
243
340
  for (const id of [IDS.perpsHome, IDS.market, IDS.proMarket]) {
244
341
  if (ids.has(id)) return id;
245
342
  }
@@ -15,11 +15,22 @@ function text(value) {
15
15
  function pageRoute(node) {
16
16
  const page = text(node?.page);
17
17
  if (!page) return undefined;
18
- if (PAGE_ROUTES[page]) return { page, ...PAGE_ROUTES[page] };
18
+ const mode = text(node?.mode)?.toLowerCase() ?? 'lite';
19
+ if (!['lite', 'pro'].includes(mode)) {
20
+ throw new Error('mobile ui.navigate mode must be lite or pro.');
21
+ }
22
+ if (PAGE_ROUTES[page]) {
23
+ const route = PAGE_ROUTES[page];
24
+ return {
25
+ page,
26
+ ...route,
27
+ params: page.startsWith('perps') ? { ...route.params, mode } : route.params,
28
+ };
29
+ }
19
30
  if (page === 'perps-market') {
20
31
  const market = text(node.market) ?? text(node.symbol) ?? text(node.params?.market?.symbol);
21
32
  if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
22
- return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market } } };
33
+ return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market }, mode } };
23
34
  }
24
35
  throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
25
36
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -45,6 +46,18 @@ function selectedAddress(status, input) {
45
46
  return String(selectedEntry(status, input)?.account?.address ?? '').toLowerCase();
46
47
  }
47
48
 
49
+ export function redactedSelectedIdentity(expectedAddress, actualAddress) {
50
+ const expected = String(expectedAddress ?? '').toLowerCase();
51
+ const actual = String(actualAddress ?? '').toLowerCase();
52
+ if (!expected || actual !== expected) {
53
+ throw new Error('Mobile wallet import did not visibly select the expected account.');
54
+ }
55
+ return {
56
+ selectedIdentitySha256: createHash('sha256').update(expected).digest('hex'),
57
+ selectedAddressMatched: true,
58
+ };
59
+ }
60
+
48
61
  function walletReady(observed, input) {
49
62
  const route = String(selectedEntry(observed, input)?.route?.name ?? '');
50
63
  return Boolean(
@@ -199,9 +212,7 @@ async function importThroughUi(input, timeoutMs, options) {
199
212
  observed = await status(input);
200
213
  }
201
214
  const actualAddress = selectedAddress(observed, input);
202
- if (actualAddress !== credentials.expectedAddress) {
203
- throw new Error(`Wallet import selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
204
- }
215
+ redactedSelectedIdentity(credentials.expectedAddress, actualAddress);
205
216
  const { consent } = await setMobileConsent(
206
217
  input,
207
218
  metametricsEnabled,
@@ -255,7 +266,7 @@ runAdapter(async (input) => {
255
266
  method: proof.alreadyReady ? 'native-profile' : 'native-ui-import',
256
267
  credentialSource: credentials.source,
257
268
  credentialSourceName: credentials.sourceName,
258
- selectedAddress: proof.selectedAddress,
269
+ ...redactedSelectedIdentity(credentials.expectedAddress, proof.selectedAddress),
259
270
  metametricsRequested: metametricsEnabled,
260
271
  biometricsRequested: biometricsEnabled,
261
272
  biometricPromptDismissed: proof.biometricPromptDismissed ?? false,
@@ -294,7 +305,7 @@ runAdapter(async (input) => {
294
305
  method: 'ui-import',
295
306
  credentialSource: imported.credentials.source,
296
307
  credentialSourceName: imported.credentials.sourceName,
297
- selectedAddress: importedEntry.account.address,
308
+ ...redactedSelectedIdentity(imported.credentials.expectedAddress, importedEntry.account.address),
298
309
  route: importedEntry.route ?? null,
299
310
  metametricsEnabled: imported.metametricsEnabled,
300
311
  interests: imported.interests,
@@ -311,16 +322,12 @@ runAdapter(async (input) => {
311
322
  ...(route === 'Login' ? { password: credentials.password } : {}),
312
323
  },
313
324
  });
314
- const actualAddress = String(unlocked.account?.address ?? address).toLowerCase();
315
- if (actualAddress !== credentials.expectedAddress) {
316
- throw new Error(`Wallet profile selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
317
- }
318
325
  return {
319
326
  action: input.action,
320
327
  method: 'profile',
321
328
  credentialSource: credentials.source,
322
329
  credentialSourceName: credentials.sourceName,
323
- selectedAddress: unlocked.account?.address ?? address,
330
+ ...redactedSelectedIdentity(credentials.expectedAddress, unlocked.account?.address ?? address),
324
331
  route: unlocked.route ?? entry.route ?? null,
325
332
  redacted: true,
326
333
  proofPath: 'mobile-fixture-profile',
@@ -0,0 +1,14 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { isOpaqueMobileRuntime, lockWalletThroughNativeUi } from './native-ui.mjs';
3
+
4
+ runAdapter(async (input) => {
5
+ if (!isOpaqueMobileRuntime(input)) {
6
+ throw new Error('metamask.wallet.lock requires an opaque Mobile runtime.');
7
+ }
8
+ return {
9
+ action: input.action,
10
+ ...await lockWalletThroughNativeUi(input),
11
+ locked: true,
12
+ proofPath: 'mobile-visible-wallet-lock-native',
13
+ };
14
+ });