@deeeed/metamask-harness 0.45.1 → 0.47.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 (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/adapters/manifest.json +12 -4
  4. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +12 -3
  5. package/adapters/mobile/wait-for-bridge.cjs +550 -0
  6. package/adapters/mobile/wait-for-bridge.sh +11 -148
  7. package/bin/mm-harness +7 -2
  8. package/dist/adapters/mobile/prepare.js +26 -18
  9. package/dist/adapters/mobile/runtime-decision.js +1 -0
  10. package/dist/cli-commands.js +2 -0
  11. package/dist/command-contract.js +7 -0
  12. package/dist/commands/checklist.js +1 -0
  13. package/dist/commands/help.js +76 -0
  14. package/dist/commands/parse-args.js +2 -1
  15. package/dist/commands/recipe-quality.js +1 -1
  16. package/dist/commands/tutorial.js +46 -0
  17. package/dist/mm-harness-cli.js +45 -6
  18. package/docs/CONTRIBUTING.md +2 -0
  19. package/docs/QA.md +2 -2
  20. package/library/actions/mobile/perps/perps.mjs +750 -35
  21. package/library/manifests/mobile.action-manifest.json +101 -6
  22. package/library/recipes/mobile/perps/chase-assert-running.recipe.json +110 -0
  23. package/library/recipes/mobile/perps/chase-place.recipe.json +145 -0
  24. package/library/recipes/mobile/perps/chase-terminate.recipe.json +98 -0
  25. package/library/recipes/mobile/perps/pro-order-setup.recipe.json +111 -0
  26. package/library/recipes/mobile/perps/pro-order-start-state.recipe.json +53 -0
  27. package/library/recipes/mobile/perps/scale-assert-orders.recipe.json +126 -0
  28. package/library/recipes/mobile/perps/scale-place.recipe.json +186 -0
  29. package/library/recipes/mobile/perps/twap-assert-active.recipe.json +97 -0
  30. package/library/recipes/mobile/perps/twap-place.recipe.json +146 -0
  31. package/package.json +1 -1
  32. package/scripts/site-contrast.mjs +6 -2
  33. package/site/architecture.html +3 -3
  34. package/site/assets/help-recipes.json +113 -0
  35. package/site/assets/help-recipes.mjs +25 -0
  36. package/site/assets/progress.mjs +2 -0
  37. package/site/ecosystem.html +2 -2
  38. package/site/how-it-works.html +5 -0
  39. package/site/index.html +65 -13
  40. package/site/perps-advanced-orders-qa.html +96 -0
  41. package/site/perps.html +11 -0
  42. package/site/recipes.html +7 -0
  43. package/site/tutorials/v1.html +4 -0
@@ -4,10 +4,13 @@ import {
4
4
  evalAsync,
5
5
  isTargetTransition,
6
6
  navigate,
7
+ routeSatisfiesNavigationTarget,
7
8
  runAdapter,
9
+ waitForRoute,
8
10
  } from '../platform/bridge.mjs';
9
11
  import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
10
12
  import { selectAccount } from '../wallet/select_account.mjs';
13
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
11
14
 
12
15
  function sleep(ms) {
13
16
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -80,7 +83,31 @@ const MODE_ROOT_TEST_IDS = {
80
83
  };
81
84
  const READ_ATTEMPT_TIMEOUT_MS = 10_000;
82
85
 
83
- function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
86
+ function inheritedDeadline(input) {
87
+ const deadline = Number(input.node?._action_deadline_epoch_ms);
88
+ return Number.isFinite(deadline) ? deadline : null;
89
+ }
90
+
91
+ function earliestDeadline(input, deadline) {
92
+ const inherited = inheritedDeadline(input);
93
+ return inherited === null ? deadline : Math.min(deadline, inherited);
94
+ }
95
+
96
+ function deadlineFromTimeout(input, timeoutMs) {
97
+ return earliestDeadline(
98
+ input,
99
+ Date.now() + Math.max(0, Number(timeoutMs)),
100
+ );
101
+ }
102
+
103
+ async function sleepBeforeDeadline(deadline, maximumMs, wait = sleep) {
104
+ const remainingMs = deadline - Date.now();
105
+ if (remainingMs <= 0) return;
106
+ await wait(Math.min(maximumMs, remainingMs));
107
+ }
108
+
109
+ function withRemainingDeadline(input, requestedDeadline, attemptTimeoutMs) {
110
+ const deadline = earliestDeadline(input, requestedDeadline);
84
111
  const remainingMs = Math.max(1, deadline - Date.now());
85
112
  const boundedTimeout = (configured) =>
86
113
  Math.min(
@@ -93,6 +120,7 @@ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
93
120
  node: {
94
121
  ...input.node,
95
122
  _action_deadline_epoch_ms: deadline,
123
+ timeout_ms: boundedTimeout(input.node?.timeout_ms),
96
124
  bridge_timeout_ms: boundedTimeout(input.node?.bridge_timeout_ms),
97
125
  cdp_timeout_ms: boundedTimeout(input.node?.cdp_timeout_ms),
98
126
  controller_ready_timeout_ms: boundedTimeout(
@@ -147,7 +175,10 @@ export async function ensureMode(input) {
147
175
  throw new Error('metamask.perps.ensure_mode requires mode=lite|pro.');
148
176
  }
149
177
  const otherMode = requestedMode === 'lite' ? 'pro' : 'lite';
150
- const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
178
+ const deadline = deadlineFromTimeout(
179
+ input,
180
+ Number(input.node?.timeout_ms ?? 30_000),
181
+ );
151
182
  let switched = false;
152
183
 
153
184
  while (Date.now() < deadline) {
@@ -184,7 +215,7 @@ export async function ensureMode(input) {
184
215
  switched = true;
185
216
  }
186
217
  }
187
- await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
218
+ await sleepBeforeDeadline(deadline, 250);
188
219
  }
189
220
 
190
221
  throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
@@ -192,9 +223,19 @@ export async function ensureMode(input) {
192
223
 
193
224
  export async function retryPerpsClientRead(
194
225
  read,
195
- { timeoutMs = 20000, intervalMs = 500, sleep: wait = sleep } = {},
226
+ {
227
+ timeoutMs = 20000,
228
+ intervalMs = 500,
229
+ sleep: wait = sleep,
230
+ deadlineEpochMs,
231
+ } = {},
196
232
  ) {
197
- const deadline = Date.now() + timeoutMs;
233
+ const localDeadline = Date.now() + Math.max(0, timeoutMs);
234
+ const configuredDeadline = Number(deadlineEpochMs);
235
+ const deadline =
236
+ deadlineEpochMs == null || !Number.isFinite(configuredDeadline)
237
+ ? localDeadline
238
+ : Math.min(localDeadline, configuredDeadline);
198
239
  while (true) {
199
240
  try {
200
241
  return await read();
@@ -204,7 +245,7 @@ export async function retryPerpsClientRead(
204
245
  }
205
246
  const remainingMs = deadline - Date.now();
206
247
  if (remainingMs <= 0) throw error;
207
- await wait(Math.min(intervalMs, remainingMs));
248
+ await sleepBeforeDeadline(deadline, intervalMs, wait);
208
249
  }
209
250
  }
210
251
  }
@@ -329,6 +370,7 @@ async function readPositions(input) {
329
370
  }
330
371
  return positions;
331
372
  },
373
+ { deadlineEpochMs: inheritedDeadline(input) },
332
374
  );
333
375
  }
334
376
 
@@ -355,9 +397,227 @@ async function readOpenOrders(input) {
355
397
  }
356
398
  return orders;
357
399
  },
400
+ { deadlineEpochMs: inheritedDeadline(input) },
401
+ );
402
+ }
403
+
404
+ async function readStrategyOrders(input) {
405
+ return retryPerpsClientRead(
406
+ async () => {
407
+ const orders = await evalAsync(
408
+ input,
409
+ `(function(){
410
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
411
+ if (!controller || typeof controller.getTwapOrders !== 'function' || typeof controller.getChaseOrders !== 'function') {
412
+ throw new Error('PerpsController strategy readers are unavailable; cannot clean live TWAP and Chase orders.');
413
+ }
414
+ return Promise.all([
415
+ controller.getTwapOrders(),
416
+ controller.getChaseOrders()
417
+ ]).then(function(values){
418
+ if (!Array.isArray(values[0]) || !Array.isArray(values[1])) {
419
+ throw new Error('PerpsController strategy readers returned a non-array result.');
420
+ }
421
+ return JSON.stringify({ twap: values[0], chase: values[1] });
422
+ });
423
+ })()`,
424
+ );
425
+ if (!Array.isArray(orders?.twap) || !Array.isArray(orders?.chase)) {
426
+ throw new Error(
427
+ 'PerpsController strategy readers returned an invalid result.',
428
+ );
429
+ }
430
+ return orders;
431
+ },
432
+ { deadlineEpochMs: inheritedDeadline(input) },
433
+ );
434
+ }
435
+
436
+ function selectedActiveTwapOrders(input, orders) {
437
+ return selectedItems(input, orders).filter(
438
+ (order) => String(order?.status ?? '').toLowerCase() === 'active',
439
+ );
440
+ }
441
+
442
+ const ACTIVE_CHASE_STATUSES = new Set([
443
+ 'active',
444
+ 'backgrounded',
445
+ 'termination_pending',
446
+ ]);
447
+
448
+ function selectedActiveChaseOrders(input, orders) {
449
+ return selectedItems(input, orders).filter((order) =>
450
+ ACTIVE_CHASE_STATUSES.has(String(order?.status ?? '').toLowerCase()),
451
+ );
452
+ }
453
+
454
+ async function waitForStrategyOrdersAbsent(
455
+ input,
456
+ targetKeys,
457
+ timeoutMs = 30000,
458
+ ) {
459
+ const deadline = deadlineFromTimeout(input, timeoutMs);
460
+ let activeTwapOrders = [];
461
+ let activeChaseOrders = [];
462
+ while (true) {
463
+ const orders = await readStrategyOrders(input);
464
+ activeTwapOrders = selectedActiveTwapOrders(
465
+ input,
466
+ orders.twap,
467
+ ).filter((order) =>
468
+ targetKeys.has(
469
+ strategyTargetKey({
470
+ orderId: order.orderId,
471
+ orderType: 'twap',
472
+ providerId: order.providerId,
473
+ }),
474
+ ),
475
+ );
476
+ activeChaseOrders = selectedActiveChaseOrders(
477
+ input,
478
+ orders.chase,
479
+ ).filter((order) =>
480
+ targetKeys.has(
481
+ strategyTargetKey({
482
+ orderId: order.handle,
483
+ orderType: 'chase',
484
+ providerId: order.providerId,
485
+ }),
486
+ ),
487
+ );
488
+ if (activeTwapOrders.length === 0 && activeChaseOrders.length === 0) {
489
+ return { activeTwapOrders, activeChaseOrders };
490
+ }
491
+ if (Date.now() >= deadline) {
492
+ return { activeTwapOrders, activeChaseOrders };
493
+ }
494
+ await sleepBeforeDeadline(deadline, 500);
495
+ }
496
+ }
497
+
498
+ function strategyTargetKey(target) {
499
+ return [
500
+ target.orderType,
501
+ target.providerId ?? '',
502
+ String(target.orderId),
503
+ ].join(':');
504
+ }
505
+
506
+ function isAlreadyAbsentStrategyResult(entry) {
507
+ return (
508
+ entry.success !== true &&
509
+ /never placed, already canceled, or filled/iu.test(
510
+ String(entry.result?.error ?? entry.error ?? ''),
511
+ )
358
512
  );
359
513
  }
360
514
 
515
+ async function closeStrategyOrders(input) {
516
+ requireExplicitSelection(input);
517
+ const orders = await readStrategyOrders(input);
518
+ const twapOrders = selectedActiveTwapOrders(input, orders.twap);
519
+ const chaseOrders = selectedActiveChaseOrders(input, orders.chase);
520
+ const targets = [
521
+ ...twapOrders.map((order) => ({
522
+ orderId: String(order.orderId ?? ''),
523
+ symbol: symbolForItem(order),
524
+ orderType: 'twap',
525
+ providerId: order.providerId,
526
+ })),
527
+ ...chaseOrders.map((order) => ({
528
+ orderId: String(order.handle ?? ''),
529
+ symbol: symbolForItem(order),
530
+ orderType: 'chase',
531
+ providerId: order.providerId,
532
+ })),
533
+ ];
534
+ const invalid = targets.find((target) => !target.orderId || !target.symbol);
535
+ if (invalid) {
536
+ throw new Error(
537
+ `Cannot cancel selected Perps strategy without an order ID and symbol: ${JSON.stringify(invalid)}`,
538
+ );
539
+ }
540
+ if (targets.length === 0) {
541
+ return {
542
+ action: input.action,
543
+ changed: false,
544
+ canceled: false,
545
+ matchingCount: 0,
546
+ twapCount: 0,
547
+ chaseCount: 0,
548
+ acceptedCount: 0,
549
+ alreadyAbsentCount: 0,
550
+ proofPath: 'mobile-perps-controller-cancel-strategies',
551
+ };
552
+ }
553
+
554
+ const result = await evalAsync(
555
+ input,
556
+ `(function(){
557
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
558
+ var targets = ${JSON.stringify(targets)};
559
+ if (!controller || typeof controller.cancelOrder !== 'function') {
560
+ throw new Error('Engine.context.PerpsController.cancelOrder is unavailable; cannot cancel live Perps strategies.');
561
+ }
562
+ return targets.reduce(function(chain, target){
563
+ return chain.then(function(results){
564
+ return controller.cancelOrder(target).then(function(value){
565
+ results.push({ target: target, success: !value || value.success !== false, result: value });
566
+ return results;
567
+ }, function(error){
568
+ results.push({ target: target, success: false, error: String(error && error.message || error) });
569
+ return results;
570
+ });
571
+ });
572
+ }, Promise.resolve([])).then(function(results){
573
+ return JSON.stringify(results);
574
+ });
575
+ })()`,
576
+ );
577
+ const failed = result.filter(
578
+ (entry) =>
579
+ entry.success !== true && !isAlreadyAbsentStrategyResult(entry),
580
+ );
581
+ if (failed.length > 0) {
582
+ throw new Error(
583
+ `Failed to cancel ${failed.length} selected Perps strategies: ${JSON.stringify(failed)}`,
584
+ );
585
+ }
586
+
587
+ const confirmedTargetKeys = new Set(
588
+ result
589
+ .filter((entry) => entry.success === true)
590
+ .map((entry) => strategyTargetKey(entry.target)),
591
+ );
592
+ const remaining =
593
+ confirmedTargetKeys.size === 0
594
+ ? { activeTwapOrders: [], activeChaseOrders: [] }
595
+ : await waitForStrategyOrdersAbsent(
596
+ input,
597
+ confirmedTargetKeys,
598
+ Number(input.node?.timeout_ms ?? 30000),
599
+ );
600
+ if (
601
+ remaining.activeTwapOrders.length > 0 ||
602
+ remaining.activeChaseOrders.length > 0
603
+ ) {
604
+ throw new Error(
605
+ `Expected selected Perps strategies to cancel, but ${remaining.activeTwapOrders.length} TWAP and ${remaining.activeChaseOrders.length} Chase orders remain active.`,
606
+ );
607
+ }
608
+ return {
609
+ action: input.action,
610
+ changed: result.some((entry) => entry.success === true),
611
+ canceled: result.some((entry) => entry.success === true),
612
+ matchingCount: targets.length,
613
+ twapCount: twapOrders.length,
614
+ chaseCount: chaseOrders.length,
615
+ acceptedCount: result.filter((entry) => entry.success === true).length,
616
+ alreadyAbsentCount: result.filter(isAlreadyAbsentStrategyResult).length,
617
+ proofPath: 'mobile-perps-controller-cancel-strategies',
618
+ };
619
+ }
620
+
361
621
  export function clearPerformanceCachesExpression() {
362
622
  return `(function(){
363
623
  var metroRequire = globalThis.__r;
@@ -459,43 +719,43 @@ async function clearPerformanceCaches(input) {
459
719
  }
460
720
 
461
721
  async function waitForPositionsAbsent(input, symbols, timeoutMs = 30000) {
462
- const deadline = Date.now() + timeoutMs;
722
+ const deadline = deadlineFromTimeout(input, timeoutMs);
463
723
  let last = [];
464
724
  while (Date.now() < deadline) {
465
725
  last = await readPositions(input);
466
726
  const remaining = last.filter((position) => symbols.includes(symbolForItem(position)));
467
727
  if (remaining.length === 0) return last;
468
- await sleep(500);
728
+ await sleepBeforeDeadline(deadline, 500);
469
729
  }
470
730
  return last;
471
731
  }
472
732
 
473
733
  async function waitForOrdersAbsent(input, symbols, timeoutMs = 30000) {
474
- const deadline = Date.now() + timeoutMs;
734
+ const deadline = deadlineFromTimeout(input, timeoutMs);
475
735
  let last = [];
476
736
  while (Date.now() < deadline) {
477
737
  last = await readOpenOrders(input);
478
738
  const remaining = last.filter((order) => symbols.includes(symbolForItem(order)));
479
739
  if (remaining.length === 0) return last;
480
- await sleep(500);
740
+ await sleepBeforeDeadline(deadline, 500);
481
741
  }
482
742
  return last;
483
743
  }
484
744
 
485
745
  async function waitForPositionPresent(input, symbol, timeoutMs = 30000) {
486
- const deadline = Date.now() + timeoutMs;
746
+ const deadline = deadlineFromTimeout(input, timeoutMs);
487
747
  let last = [];
488
748
  while (Date.now() < deadline) {
489
749
  last = await readPositions(input);
490
750
  const matching = last.filter((position) => symbolForItem(position) === symbol);
491
751
  if (matching.length > 0) return last;
492
- await sleep(500);
752
+ await sleepBeforeDeadline(deadline, 500);
493
753
  }
494
754
  return last;
495
755
  }
496
756
 
497
757
  async function waitForSelectedState(input, readItems, expectedOpen, timeoutMs) {
498
- const deadline = Date.now() + Math.max(0, timeoutMs);
758
+ const deadline = deadlineFromTimeout(input, timeoutMs);
499
759
  let items;
500
760
  while (true) {
501
761
  items = await readItems(input);
@@ -504,7 +764,7 @@ async function waitForSelectedState(input, readItems, expectedOpen, timeoutMs) {
504
764
  return matching;
505
765
  }
506
766
  if (Date.now() >= deadline) return matching;
507
- await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
767
+ await sleepBeforeDeadline(deadline, 500);
508
768
  }
509
769
  }
510
770
 
@@ -605,18 +865,311 @@ function isTransientClosePriceError(result) {
605
865
  return /IOC_CANCEL|Slippage/i.test(message);
606
866
  }
607
867
 
868
+ function isPerpsMarketSelectorRoute(route) {
869
+ return ['PerpsMarketListView', 'PerpsTrendingView'].includes(route?.name);
870
+ }
871
+
872
+ function marketSymbolFromRoute(route) {
873
+ const symbol = route?.params?.market?.symbol ?? route?.params?.symbol;
874
+ return String(symbol ?? '').trim() ? normalizeMarketSymbol(symbol) : null;
875
+ }
876
+
877
+ const PERPS_MARKET_DETAILS_BACK_TEST_IDS = [
878
+ 'perps-pro-market-header-back-button',
879
+ 'perps-market-header-back-button',
880
+ ];
881
+ const PERPS_MARKET_DETAILS_SCROLL_TEST_IDS = [
882
+ 'perps-pro-market-scroll-view',
883
+ 'perps-market-details-view',
884
+ ];
885
+
886
+ async function readCurrentRoute(input) {
887
+ try {
888
+ return await bridgeCommand(input, ['get-route']);
889
+ } catch (error) {
890
+ if (!isTargetTransition(error)) throw error;
891
+ return null;
892
+ }
893
+ }
894
+
895
+ async function readVisiblePerpsTestIds(input, deadline) {
896
+ const boundedInput = withRemainingDeadline(
897
+ input,
898
+ deadline,
899
+ READ_ATTEMPT_TIMEOUT_MS,
900
+ );
901
+ const observed = await observeNativeUi(
902
+ { refs: ['ui.visible'], node: boundedInput.node },
903
+ input.context,
904
+ );
905
+ const visible = observed.observations?.['ui.visible'];
906
+ if (!visible) {
907
+ const detail = observed.warnings
908
+ ?.map((warning) => warning.message)
909
+ .filter(Boolean)
910
+ .join('; ');
911
+ throw new Error(
912
+ `Visible Perps market navigation could not read native accessibility${detail ? `: ${detail}` : '.'}`,
913
+ );
914
+ }
915
+ return new Set(visible.items.map((item) => String(item.test_id ?? '')));
916
+ }
917
+
918
+ async function waitForPerpsUiTarget(input, testId, deadline) {
919
+ let lastTestIds = [];
920
+ while (Date.now() < deadline) {
921
+ const testIds = await readVisiblePerpsTestIds(input, deadline);
922
+ if (testIds.has(testId)) return testIds;
923
+ lastTestIds = [...testIds].filter(Boolean).slice(0, 20);
924
+ await sleepBeforeDeadline(deadline, 250);
925
+ }
926
+ throw new Error(
927
+ `Perps control ${testId} did not become visible before timeout. Observed: ${lastTestIds.join(', ') || 'none'}.`,
928
+ );
929
+ }
930
+
931
+ async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
932
+ const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
933
+ const deadline = deadlineFromTimeout(input, timeoutMs);
934
+ const params = { market: { symbol } };
935
+ const rowTestId = `perps-market-row-item-${symbol}`;
936
+ const searchTestId = 'perps-market-list-search-bar';
937
+ let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
938
+ let usedSearch = false;
939
+
940
+ if (!visibleTestIds.has(rowTestId)) {
941
+ if (!visibleTestIds.has(searchTestId)) {
942
+ if (!visibleTestIds.has('perps-home-search-toggle')) {
943
+ throw new Error(
944
+ 'Perps Home has no visible market row, search field, or search control.',
945
+ );
946
+ }
947
+ await bridgeCommand(withRemainingDeadline(input, deadline), [
948
+ 'press-test-id',
949
+ 'perps-home-search-toggle',
950
+ ]);
951
+ visibleTestIds = await waitForPerpsUiTarget(
952
+ input,
953
+ searchTestId,
954
+ deadline,
955
+ );
956
+ }
957
+ await bridgeCommand(withRemainingDeadline(input, deadline), [
958
+ 'set-input',
959
+ searchTestId,
960
+ symbol,
961
+ ]);
962
+ visibleTestIds = await waitForPerpsUiTarget(
963
+ input,
964
+ rowTestId,
965
+ deadline,
966
+ );
967
+ usedSearch = true;
968
+ }
969
+
970
+ await bridgeCommand(withRemainingDeadline(input, deadline), [
971
+ 'press-test-id',
972
+ rowTestId,
973
+ ]);
974
+ const currentRoute = await waitForRoute(
975
+ withRemainingDeadline(input, deadline),
976
+ 'PerpsMarketDetails',
977
+ Math.max(1, deadline - Date.now()),
978
+ params,
979
+ );
980
+ return {
981
+ navigated: 'PerpsMarketDetails',
982
+ params,
983
+ previousRoute,
984
+ currentRoute,
985
+ verifiedRoute: 'PerpsMarketDetails',
986
+ visibleMarketSelection: { usedSearch },
987
+ };
988
+ }
989
+
990
+ async function waitForRouteChange(input, previousRoute, deadline) {
991
+ let currentRoute = previousRoute;
992
+ while (Date.now() < deadline) {
993
+ currentRoute = await readCurrentRoute(
994
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
995
+ );
996
+ if (
997
+ currentRoute &&
998
+ (currentRoute.name !== previousRoute?.name ||
999
+ currentRoute.key !== previousRoute?.key)
1000
+ ) {
1001
+ return currentRoute;
1002
+ }
1003
+ await sleepBeforeDeadline(deadline, 250);
1004
+ }
1005
+ throw new Error('Perps back control did not change the current route.');
1006
+ }
1007
+
1008
+ async function navigateFromMarketDetailsToHome(input, previousRoute) {
1009
+ const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
1010
+ const deadline = deadlineFromTimeout(input, timeoutMs);
1011
+ let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
1012
+ let backTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS.find((testId) =>
1013
+ visibleTestIds.has(testId),
1014
+ );
1015
+ if (!backTestId) {
1016
+ for (const [index, scrollTestId] of
1017
+ PERPS_MARKET_DETAILS_SCROLL_TEST_IDS.entries()) {
1018
+ try {
1019
+ await bridgeCommand(withRemainingDeadline(input, deadline), [
1020
+ 'scroll-view',
1021
+ '--test-id',
1022
+ scrollTestId,
1023
+ '--offset',
1024
+ '0',
1025
+ '--no-animated',
1026
+ ]);
1027
+ const expectedBackTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS[index];
1028
+ visibleTestIds = await waitForPerpsUiTarget(
1029
+ input,
1030
+ expectedBackTestId,
1031
+ deadline,
1032
+ );
1033
+ backTestId = expectedBackTestId;
1034
+ break;
1035
+ } catch (error) {
1036
+ if (!/No scrollable near testID=/u.test(String(error))) throw error;
1037
+ }
1038
+ }
1039
+ }
1040
+ if (!backTestId) {
1041
+ throw new Error(
1042
+ 'Perps market details has no visible back control for returning home.',
1043
+ );
1044
+ }
1045
+ const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
1046
+ 'press-test-id',
1047
+ backTestId,
1048
+ ]);
1049
+ if (pressed?.ok === false) {
1050
+ throw new Error(String(pressed.error || 'Perps back control press failed.'));
1051
+ }
1052
+ const intermediateRoute = await waitForRouteChange(
1053
+ input,
1054
+ previousRoute,
1055
+ deadline,
1056
+ );
1057
+ if (
1058
+ routeSatisfiesNavigationTarget(intermediateRoute, 'PerpsMarketListView')
1059
+ ) {
1060
+ return {
1061
+ navigated: 'PerpsMarketListView',
1062
+ params: {},
1063
+ previousRoute,
1064
+ currentRoute: intermediateRoute,
1065
+ verifiedRoute: 'PerpsMarketListView',
1066
+ visibleBackNavigation: { testId: backTestId },
1067
+ };
1068
+ }
1069
+ const navigation = await navigate(
1070
+ withRemainingDeadline(input, deadline),
1071
+ 'PerpsMarketListView',
1072
+ {},
1073
+ );
1074
+ return {
1075
+ ...navigation,
1076
+ previousRoute,
1077
+ intermediateRoute,
1078
+ visibleBackNavigation: { testId: backTestId },
1079
+ };
1080
+ }
1081
+
608
1082
  async function navigatePerps(input) {
609
1083
  const selected = String(
610
1084
  input.node?.target ?? input.node?.destination ?? defaultNavigationTarget(input.node),
611
1085
  ).toLowerCase();
612
1086
  if (selected === 'home' || selected === 'perps' || selected === 'perps_home') {
613
- const navigation = await navigate(input, 'PerpsMarketListView', {});
614
- return { action: input.action, target: selected, navigation, proofPath: 'agentic-navigation' };
1087
+ const currentRoute = await readCurrentRoute(input);
1088
+ if (routeSatisfiesNavigationTarget(currentRoute, 'PerpsMarketListView')) {
1089
+ return {
1090
+ action: input.action,
1091
+ target: selected,
1092
+ navigation: {
1093
+ navigated: 'PerpsMarketListView',
1094
+ params: {},
1095
+ previousRoute: currentRoute,
1096
+ currentRoute,
1097
+ verifiedRoute: 'PerpsMarketListView',
1098
+ alreadyAtRoute: true,
1099
+ },
1100
+ proofPath: 'agentic-navigation',
1101
+ };
1102
+ }
1103
+ const navigation = currentRoute?.name === 'PerpsMarketDetails'
1104
+ ? await navigateFromMarketDetailsToHome(input, currentRoute)
1105
+ : await navigate(input, 'PerpsMarketListView', {});
1106
+ return {
1107
+ action: input.action,
1108
+ target: selected,
1109
+ navigation,
1110
+ proofPath: navigation.visibleBackNavigation
1111
+ ? 'visible-native-back-navigation'
1112
+ : 'agentic-navigation',
1113
+ };
615
1114
  }
616
1115
  if (selected === 'market' || selected === 'market_details') {
1116
+ const currentRoute = await readCurrentRoute(input);
1117
+ const requestedMarket = input.node?.market ?? input.node?.symbol;
1118
+ if (
1119
+ !String(requestedMarket ?? '').trim() &&
1120
+ routeSatisfiesNavigationTarget(currentRoute, 'PerpsMarketDetails')
1121
+ ) {
1122
+ return {
1123
+ action: input.action,
1124
+ target: selected,
1125
+ market: marketSymbolFromRoute(currentRoute),
1126
+ navigation: {
1127
+ navigated: 'PerpsMarketDetails',
1128
+ params: currentRoute?.params ?? {},
1129
+ previousRoute: currentRoute,
1130
+ currentRoute,
1131
+ verifiedRoute: 'PerpsMarketDetails',
1132
+ alreadyAtRoute: true,
1133
+ },
1134
+ proofPath: 'agentic-navigation',
1135
+ };
1136
+ }
617
1137
  const symbol = marketSymbol(input);
618
- const navigation = await navigate(input, 'PerpsMarketDetails', { market: { symbol } });
619
- return { action: input.action, target: selected, market: symbol, navigation, proofPath: 'agentic-navigation' };
1138
+ const params = { market: { symbol } };
1139
+ if (
1140
+ routeSatisfiesNavigationTarget(
1141
+ currentRoute,
1142
+ 'PerpsMarketDetails',
1143
+ params,
1144
+ )
1145
+ ) {
1146
+ return {
1147
+ action: input.action,
1148
+ target: selected,
1149
+ market: symbol,
1150
+ navigation: {
1151
+ navigated: 'PerpsMarketDetails',
1152
+ params,
1153
+ previousRoute: currentRoute,
1154
+ currentRoute,
1155
+ verifiedRoute: 'PerpsMarketDetails',
1156
+ alreadyAtRoute: true,
1157
+ },
1158
+ proofPath: 'agentic-navigation',
1159
+ };
1160
+ }
1161
+ const navigation = isPerpsMarketSelectorRoute(currentRoute)
1162
+ ? await selectPerpsMarketFromHome(input, symbol, currentRoute)
1163
+ : await navigate(input, 'PerpsMarketDetails', params);
1164
+ return {
1165
+ action: input.action,
1166
+ target: selected,
1167
+ market: symbol,
1168
+ navigation,
1169
+ proofPath: navigation.visibleMarketSelection
1170
+ ? 'visible-native-market-selection'
1171
+ : 'agentic-navigation',
1172
+ };
620
1173
  }
621
1174
  throw new Error(`Unsupported mobile Perps navigation target: ${selected}`);
622
1175
  }
@@ -681,10 +1234,15 @@ function closePositionParams(input, position) {
681
1234
  async function closePositionItem(input, position) {
682
1235
  const maxAttempts = Number(input.node?.close_attempts ?? 3);
683
1236
  const retryDelayMs = Number(input.node?.close_retry_delay_ms ?? 1000);
1237
+ const deadline = deadlineFromTimeout(
1238
+ input,
1239
+ Number(input.node?.timeout_ms ?? 30_000),
1240
+ );
684
1241
  const baseParams = closePositionParams(input, position);
685
1242
  const attempts = [];
686
1243
  let lastResult = null;
687
1244
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1245
+ if (Date.now() >= deadline) break;
688
1246
  const currentPositions = await readPositions(input);
689
1247
  const currentPosition = currentPositions.find((candidate) => symbolForItem(candidate) === baseParams.symbol);
690
1248
  if (!currentPosition) return { symbol: baseParams.symbol, result: { success: true, alreadyClosed: true }, attempts };
@@ -699,7 +1257,7 @@ async function closePositionItem(input, position) {
699
1257
  lastResult = result;
700
1258
  if (result?.success === false) {
701
1259
  if (isTransientClosePriceError(result)) {
702
- await sleep(retryDelayMs);
1260
+ await sleepBeforeDeadline(deadline, retryDelayMs);
703
1261
  } else {
704
1262
  break;
705
1263
  }
@@ -872,7 +1430,8 @@ function paramsForState(input) {
872
1430
  }
873
1431
 
874
1432
  function profileDefaults(profile) {
875
- const selected = String(profile ?? 'clean_market_testnet');
1433
+ const selected = String(profile ?? '').trim();
1434
+ if (!selected) return {};
876
1435
  if (selected === 'clean_market_testnet') {
877
1436
  return {
878
1437
  provider: 'hyperliquid',
@@ -880,6 +1439,7 @@ function profileDefaults(profile) {
880
1439
  page: 'market',
881
1440
  positions: { state: 'none', mode: 'matching' },
882
1441
  orders: { state: 'none', mode: 'matching' },
1442
+ strategies: { state: 'none', mode: 'matching' },
883
1443
  };
884
1444
  }
885
1445
  if (selected === 'open_position_testnet') {
@@ -927,9 +1487,40 @@ function mergeStateConfig(defaults, params) {
927
1487
  ...params,
928
1488
  positions: mergeNested(defaults.positions, params.positions),
929
1489
  orders: mergeNested(defaults.orders, params.orders),
1490
+ strategies: mergeNested(defaults.strategies, params.strategies),
930
1491
  };
931
1492
  }
932
1493
 
1494
+ export function applyTradingStatePolicy(config) {
1495
+ const policy = config.trading_state;
1496
+ if (policy === undefined) return config;
1497
+ if (policy === 'preserve') {
1498
+ return { ...config, positions: false, orders: false, strategies: false };
1499
+ }
1500
+ if (policy === 'clean_selected_testnet_market') {
1501
+ if (!config.market && !config.symbol) {
1502
+ throw new Error(
1503
+ 'metamask.perps.start_state trading_state=clean_selected_testnet_market requires market or symbol.',
1504
+ );
1505
+ }
1506
+ const selectedMarketState = {
1507
+ state: 'none',
1508
+ mode: 'matching',
1509
+ };
1510
+ return String(config.network).toLowerCase() === 'testnet'
1511
+ ? {
1512
+ ...config,
1513
+ positions: selectedMarketState,
1514
+ orders: selectedMarketState,
1515
+ strategies: selectedMarketState,
1516
+ }
1517
+ : { ...config, positions: false, orders: false, strategies: false };
1518
+ }
1519
+ throw new Error(
1520
+ `metamask.perps.start_state received unsupported trading_state: ${String(policy)}`,
1521
+ );
1522
+ }
1523
+
933
1524
  function mergeNested(defaultValue, overrideValue) {
934
1525
  if (overrideValue === false) return false;
935
1526
  if (overrideValue === undefined) return defaultValue;
@@ -939,34 +1530,66 @@ function mergeNested(defaultValue, overrideValue) {
939
1530
  return overrideValue;
940
1531
  }
941
1532
 
1533
+ function resolveChildTimeoutMs(input, node, params) {
1534
+ const deadline = Number(input.node?._action_deadline_epoch_ms);
1535
+ const limits = [
1536
+ Number(node.timeout_ms ?? params.timeout_ms),
1537
+ Number(input.node?.timeout_ms),
1538
+ ];
1539
+ if (Number.isFinite(deadline)) {
1540
+ limits.push(deadline - Date.now());
1541
+ }
1542
+ const finiteLimits = limits.filter(Number.isFinite);
1543
+ return finiteLimits.length > 0
1544
+ ? Math.max(1, Math.min(...finiteLimits))
1545
+ : undefined;
1546
+ }
1547
+
942
1548
  function childInput(input, node) {
943
1549
  const params = paramsForState(input);
1550
+ const deadline = inheritedDeadline(input);
944
1551
  return {
945
1552
  ...input,
946
1553
  node: {
947
1554
  ...params,
948
1555
  ...node,
1556
+ ...(deadline === null ? {} : { _action_deadline_epoch_ms: deadline }),
949
1557
  market: node.market ?? node.symbol ?? params.market ?? params.symbol,
950
1558
  markets: node.markets ?? params.markets,
951
1559
  symbols: node.symbols ?? params.symbols,
952
1560
  side: node.side ?? params.side,
953
- timeout_ms: node.timeout_ms ?? params.timeout_ms,
1561
+ timeout_ms: resolveChildTimeoutMs(input, node, params),
954
1562
  },
955
1563
  };
956
1564
  }
957
1565
 
958
1566
  async function applyOrdersState(input, config) {
959
- if (config === false) return { skipped: true };
1567
+ if (config === false || config === undefined) return { skipped: true };
960
1568
  const node = config && typeof config === 'object' ? config : { state: String(config ?? 'none') };
961
1569
  return ensureOrders(childInput(input, node));
962
1570
  }
963
1571
 
964
1572
  async function applyPositionsState(input, config) {
965
- if (config === false) return { skipped: true };
1573
+ if (config === false || config === undefined) return { skipped: true };
966
1574
  const node = config && typeof config === 'object' ? config : { state: String(config ?? 'none') };
967
1575
  return ensurePositions(childInput(input, node));
968
1576
  }
969
1577
 
1578
+ async function applyStrategiesState(input, config) {
1579
+ if (config === false || config === undefined) return { skipped: true };
1580
+ const node =
1581
+ config && typeof config === 'object'
1582
+ ? config
1583
+ : { state: String(config ?? 'none') };
1584
+ const state = String(node.state ?? 'none').toLowerCase();
1585
+ if (!['none', 'closed', 'absent'].includes(state)) {
1586
+ throw new Error(
1587
+ `metamask.perps.start_state received unsupported strategy state: ${state}`,
1588
+ );
1589
+ }
1590
+ return closeStrategyOrders(childInput(input, node));
1591
+ }
1592
+
970
1593
  async function applyStateNavigation(input, config) {
971
1594
  if (!config.page && !config.market && !config.symbol) return { skipped: true };
972
1595
  const target = config.page ?? defaultNavigationTarget(config);
@@ -984,6 +1607,28 @@ async function readPerpsRuntimeState(input) {
984
1607
  `);
985
1608
  }
986
1609
 
1610
+ async function assertRequestedRuntimeState(input, config) {
1611
+ const state = await readPerpsRuntimeState(input);
1612
+ if (
1613
+ config.provider &&
1614
+ String(state.activeProvider ?? '').toLowerCase() !==
1615
+ String(config.provider).toLowerCase()
1616
+ ) {
1617
+ throw new Error(
1618
+ `Refusing Perps teardown on provider ${state.activeProvider ?? 'unknown'}; expected ${config.provider}.`,
1619
+ );
1620
+ }
1621
+ if (config.network) {
1622
+ const expectedTestnet = String(config.network).toLowerCase() === 'testnet';
1623
+ if (Boolean(state.isTestnet) !== expectedTestnet) {
1624
+ throw new Error(
1625
+ `Refusing Perps teardown on ${state.isTestnet ? 'testnet' : 'mainnet'}; expected ${config.network}.`,
1626
+ );
1627
+ }
1628
+ }
1629
+ return state;
1630
+ }
1631
+
987
1632
  async function ensureProvider(input, config) {
988
1633
  if (!config.provider) return { skipped: true };
989
1634
  const expected = String(config.provider).toLowerCase();
@@ -1018,12 +1663,12 @@ async function ensureNetwork(input, config) {
1018
1663
  }
1019
1664
 
1020
1665
  async function waitForNetworkState(input, expectedTestnet, timeoutMs) {
1021
- const deadline = Date.now() + timeoutMs;
1666
+ const deadline = deadlineFromTimeout(input, timeoutMs);
1022
1667
  let last = await readPerpsRuntimeState(input);
1023
1668
  while (Date.now() < deadline) {
1024
1669
  last = await readPerpsRuntimeState(input);
1025
1670
  if (Boolean(last.isTestnet) === expectedTestnet) return last;
1026
- await sleep(500);
1671
+ await sleepBeforeDeadline(deadline, 500);
1027
1672
  }
1028
1673
  return last;
1029
1674
  }
@@ -1032,7 +1677,7 @@ async function assertReadyToTrade(input, config) {
1032
1677
  if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
1033
1678
  const ready = await evalAsync(
1034
1679
  input,
1035
- `(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers && c.providers.get ? c.providers.get(id) : c.getActiveProvider && c.getActiveProvider(); if(!p || typeof p.isReadyToTrade !== 'function') return Promise.resolve(JSON.stringify({ready:false,activeProvider:id || null,error:'provider unavailable'})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({ready:!!(r && r.ready), activeProvider:id || null, authenticatedAddress:(r&&r.authenticatedAddress)||null}); }); })()`,
1680
+ `(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers && c.providers.get ? c.providers.get(id) : c.getActiveProvider && c.getActiveProvider(); if(!p || typeof p.isReadyToTrade !== 'function') return Promise.resolve(JSON.stringify({ready:false,activeProvider:id || null,error:'provider unavailable'})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({ready:!!(r && r.ready), activeProvider:id || null, authenticatedAddress:(r&&r.authenticatedAddress)||null, error:(r&&r.error)||null, walletConnected:r&&typeof r.walletConnected==='boolean'?r.walletConnected:null, networkSupported:r&&typeof r.networkSupported==='boolean'?r.networkSupported:null}); }); })()`,
1036
1681
  );
1037
1682
  if (!ready.ready) throw new Error(`Perps provider is not ready to trade: ${JSON.stringify(ready)}`);
1038
1683
  return ready;
@@ -1078,11 +1723,18 @@ async function applyTutorialState(input, config) {
1078
1723
 
1079
1724
  export async function startState(input) {
1080
1725
  const params = paramsForState(input);
1081
- const config = mergeStateConfig(profileDefaults(params.profile), params);
1726
+ const config = applyTradingStatePolicy(
1727
+ mergeStateConfig(profileDefaults(params.profile), params),
1728
+ );
1082
1729
  const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
1083
- const deadline = Date.now() + timeoutMs;
1730
+ const deadline = deadlineFromTimeout(input, timeoutMs);
1084
1731
  const stateInput = () => {
1085
- const remainingMs = Math.max(1, deadline - Date.now());
1732
+ const remainingMs = deadline - Date.now();
1733
+ if (remainingMs <= 0) {
1734
+ throw new Error(
1735
+ 'metamask.perps.start_state timed out before completing all phases.',
1736
+ );
1737
+ }
1086
1738
  const configuredTargetMs = Number(input.node?.target_timeout_ms);
1087
1739
  const targetTimeoutMs = Math.min(
1088
1740
  remainingMs,
@@ -1101,6 +1753,7 @@ export async function startState(input) {
1101
1753
  ...input,
1102
1754
  node: {
1103
1755
  ...input.node,
1756
+ _action_deadline_epoch_ms: deadline,
1104
1757
  timeout_ms: remainingMs,
1105
1758
  bridge_timeout_ms: remainingMs,
1106
1759
  cdp_timeout_ms: remainingMs,
@@ -1136,45 +1789,107 @@ export async function startState(input) {
1136
1789
  const navigation = await applyStateNavigation(stateInput(), config);
1137
1790
  const provider = await ensureProvider(stateInput(), config);
1138
1791
  const network = await ensureNetwork(stateInput(), config);
1792
+ const resolvedNavigation = provider.changed || network.changed
1793
+ ? await applyStateNavigation(stateInput(), config)
1794
+ : navigation;
1795
+ const modeInput = stateInput();
1796
+ const marketMode = config.market_mode
1797
+ ? await ensureMode({
1798
+ ...modeInput,
1799
+ action: 'metamask.perps.ensure_mode',
1800
+ node: {
1801
+ ...modeInput.node,
1802
+ action: 'metamask.perps.ensure_mode',
1803
+ mode: config.market_mode,
1804
+ },
1805
+ })
1806
+ : { skipped: true };
1139
1807
  const tutorial = await applyTutorialState(stateInput(), config);
1140
1808
  const readyToTrade = await assertReadyToTrade(stateInput(), config);
1141
1809
  const balance = await assertBalance(stateInput(), config);
1810
+ const strategies = await applyStrategiesState(
1811
+ stateInput(),
1812
+ config.strategies,
1813
+ );
1142
1814
  const orders = await applyOrdersState(stateInput(), config.orders);
1143
1815
  const positions = await applyPositionsState(stateInput(), config.positions);
1144
1816
  return {
1145
1817
  action: input.action,
1146
- profile: config.profile ?? params.profile ?? 'clean_market_testnet',
1818
+ profile: config.profile ?? params.profile ?? null,
1147
1819
  phase: 'start_state',
1148
1820
  wallet,
1149
1821
  account,
1150
1822
  provider,
1151
1823
  network,
1824
+ marketMode,
1152
1825
  tutorial,
1153
1826
  readyToTrade,
1154
1827
  balance,
1155
- market: config.market ?? config.symbol ?? null,
1828
+ market:
1829
+ config.market ??
1830
+ config.symbol ??
1831
+ resolvedNavigation.market ??
1832
+ navigation.market ??
1833
+ null,
1834
+ tradingState: config.trading_state ?? null,
1835
+ strategies,
1156
1836
  orders,
1157
1837
  positions,
1158
- navigation,
1838
+ navigation: resolvedNavigation,
1159
1839
  proofPath: 'metamask-perps-start-state',
1160
1840
  };
1161
1841
  }
1162
1842
 
1163
1843
  export async function teardownState(input) {
1844
+ const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
1845
+ const deadline = deadlineFromTimeout(input, timeoutMs);
1846
+ const stateInput = (phase) => {
1847
+ if (Date.now() >= deadline) {
1848
+ throw new Error(
1849
+ `metamask.perps.teardown_state timed out before ${phase}.`,
1850
+ );
1851
+ }
1852
+ return withRemainingDeadline(input, deadline);
1853
+ };
1164
1854
  const params = paramsForState(input);
1165
1855
  const defaults = {
1856
+ provider: 'hyperliquid',
1857
+ network: 'testnet',
1166
1858
  page: 'home',
1167
1859
  positions: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
1168
1860
  orders: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
1861
+ strategies: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
1169
1862
  };
1170
1863
  const config = mergeStateConfig(defaults, params);
1171
- const orders = await applyOrdersState(input, config.orders);
1172
- const positions = await applyPositionsState(input, config.positions);
1173
- const navigation = config.page === false ? { skipped: true } : await applyStateNavigation(input, config);
1864
+ const environment = await assertRequestedRuntimeState(
1865
+ stateInput('checking the requested environment'),
1866
+ config,
1867
+ );
1868
+ const strategies = await applyStrategiesState(
1869
+ stateInput('cleaning strategies'),
1870
+ config.strategies,
1871
+ );
1872
+ const orders = await applyOrdersState(
1873
+ stateInput('cleaning orders'),
1874
+ config.orders,
1875
+ );
1876
+ const positions = await applyPositionsState(
1877
+ stateInput('closing positions'),
1878
+ config.positions,
1879
+ );
1880
+ const navigation =
1881
+ config.page === false
1882
+ ? { skipped: true }
1883
+ : await applyStateNavigation(
1884
+ stateInput('restoring the requested page'),
1885
+ config,
1886
+ );
1174
1887
  return {
1175
1888
  action: input.action,
1176
1889
  phase: 'teardown',
1177
1890
  market: config.market ?? config.symbol ?? null,
1891
+ environment,
1892
+ strategies,
1178
1893
  orders,
1179
1894
  positions,
1180
1895
  navigation,