@deeeed/metamask-harness 0.86.1 → 0.87.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 (37) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/adapters/mobile/action-inputs.js +20 -0
  3. package/dist/adapters/mobile/actions.js +18 -1
  4. package/dist/adapters/mobile/android-screenrecord.js +270 -0
  5. package/dist/adapters/mobile/readiness.js +2 -1
  6. package/dist/adapters/mobile/recording-outcome.js +103 -0
  7. package/dist/adapters/mobile/recording-target.js +11 -0
  8. package/dist/adapters/mobile/run.js +48 -11
  9. package/dist/adapters/mobile/ui.js +96 -2
  10. package/dist/adapters/perps-order-inputs.js +43 -28
  11. package/dist/adapters.js +4 -1
  12. package/dist/commands/doctor.js +33 -14
  13. package/dist/commands/status-probe.js +2 -1
  14. package/docs/RECIPES.md +62 -0
  15. package/library/actions/core/perps/read_account.mjs +4 -2
  16. package/library/actions/core/perps/read_orders.mjs +4 -2
  17. package/library/actions/core/perps/read_positions.mjs +4 -2
  18. package/library/actions/extension/analytics/consent.mjs +7 -2
  19. package/library/actions/extension/perps/close_visible_position.mjs +9 -5
  20. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  21. package/library/actions/extension/perps/mutation-receipt.mjs +142 -24
  22. package/library/actions/extension/perps/perps.mjs +179 -40
  23. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  24. package/library/actions/extension/platform/cdp.mjs +6 -2
  25. package/library/actions/extension/ui/navigate.mjs +8 -0
  26. package/library/actions/mobile/perps/perps.mjs +109 -109
  27. package/library/actions/mobile/platform/bridge.mjs +192 -12
  28. package/library/actions/mobile/wallet/home.mjs +7 -2
  29. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  30. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  31. package/library/actions/shared/perps/order-policy.mjs +19 -0
  32. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  33. package/library/actions/shared/perps/visible-state.mjs +3 -2
  34. package/library/manifests/extension.action-manifest.json +3 -2
  35. package/library/manifests/mobile.action-manifest.json +30 -5
  36. package/package.json +1 -1
  37. package/site/assets/help-recipes.json +1 -0
@@ -29,6 +29,13 @@ const { resolvePort } = configModule;
29
29
  // second import path into the cjs bridge-runtime.
30
30
  export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
31
31
  export const MOBILE_BRIDGE_RESULT_ERROR_CODES = BRIDGE_RESULT_ERROR_CODES;
32
+ // Budget exhaustion, kept apart from the bridge's CDP_TIMEOUT: ACTION_DEADLINE
33
+ // is a bridge command its caller's deadline refused or cut short;
34
+ // DOMAIN_SETUP_DEADLINE is a setup phase that ran out of its slice.
35
+ export const MOBILE_BUDGET_ERROR_CODES = Object.freeze({
36
+ ACTION_DEADLINE: 'ACTION_DEADLINE',
37
+ DOMAIN_SETUP_DEADLINE: 'DOMAIN_SETUP_DEADLINE',
38
+ });
32
39
 
33
40
  const execFileAsync = promisify(execFile);
34
41
  const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
@@ -256,7 +263,11 @@ export function resolveMobileTarget(input) {
256
263
  const iosSimulator = input.node?.simulator ?? input.node?.ios_simulator ?? contextEnv.IOS_SIMULATOR ?? process.env.IOS_SIMULATOR;
257
264
  const androidDevice = input.node?.android_device ?? contextEnv.ANDROID_DEVICE ?? process.env.ANDROID_DEVICE;
258
265
  const androidTargetDeviceName = input.node?.android_target_device_name ?? input.node?.androidTargetDeviceName ?? contextEnv.ANDROID_TARGET_DEVICE_NAME ?? process.env.ANDROID_TARGET_DEVICE_NAME;
259
- const adbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL ?? androidDevice;
266
+ const explicitAdbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
267
+ const adbSerial = explicitAdbSerial ?? androidDevice;
268
+ // Only an explicit adb serial says which device class answers: ANDROID_DEVICE
269
+ // alone can be an AVD or Metro device name.
270
+ const physicalAndroid = Boolean(explicitAdbSerial) && !isAndroidEmulatorSerial(explicitAdbSerial);
260
271
  if (requestedPlatform === 'ios') {
261
272
  return {
262
273
  watcherPort,
@@ -264,6 +275,7 @@ export function resolveMobileTarget(input) {
264
275
  androidDevice: undefined,
265
276
  androidTargetDeviceName: undefined,
266
277
  adbSerial: undefined,
278
+ physicalAndroid: false,
267
279
  requestedPlatform,
268
280
  };
269
281
  }
@@ -274,6 +286,7 @@ export function resolveMobileTarget(input) {
274
286
  androidDevice,
275
287
  androidTargetDeviceName,
276
288
  adbSerial,
289
+ physicalAndroid,
277
290
  requestedPlatform,
278
291
  };
279
292
  }
@@ -283,10 +296,15 @@ export function resolveMobileTarget(input) {
283
296
  androidDevice,
284
297
  androidTargetDeviceName,
285
298
  adbSerial,
299
+ physicalAndroid,
286
300
  requestedPlatform,
287
301
  };
288
302
  }
289
303
 
304
+ export function isAndroidEmulatorSerial(serial) {
305
+ return String(serial ?? '').startsWith('emulator-');
306
+ }
307
+
290
308
  export async function openExternalDeeplink(input, url, timeout = 15000) {
291
309
  const target = resolveMobileTarget(input);
292
310
  const requestedPlatform = String(
@@ -470,17 +488,17 @@ export async function bridgeCommand(input, args) {
470
488
  ? actionDeadline - Date.now()
471
489
  : configuredTimeoutMs;
472
490
  if (remainingActionMs <= 0) {
473
- throw coded(
474
- new Error(
475
- `Mobile CDP bridge command did not start before its action deadline: ${String(args[0] ?? 'unknown')}`,
476
- ),
477
- BRIDGE_ERROR_CODES.CDP_TIMEOUT,
491
+ const error = new Error(
492
+ `ACTION_DEADLINE: bridge command ${String(args[0] ?? 'unknown')} was not started; its action deadline passed ${Math.round(-remainingActionMs)}ms ago (per-command limit ${configuredTimeoutMs}ms).`,
478
493
  );
494
+ error.deadlineEpochMs = actionDeadline;
495
+ throw coded(error, MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE);
479
496
  }
480
497
  const timeoutMs = Math.max(
481
498
  1,
482
499
  Math.min(configuredTimeoutMs, remainingActionMs),
483
500
  );
501
+ const cutByActionDeadline = timeoutMs < configuredTimeoutMs;
484
502
  const result = await new Promise((resolve, reject) => {
485
503
  const child = spawn(process.execPath, [script, ...args], {
486
504
  cwd: input.context.projectRoot,
@@ -521,8 +539,20 @@ export async function bridgeCommand(input, args) {
521
539
  });
522
540
  if (result.timedOut) {
523
541
  const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
542
+ // A timer the action deadline shortened means the caller's budget ran out,
543
+ // not the bridge: say so, and keep it out of the target-transition codes
544
+ // so route waits stop instead of retrying refused commands.
545
+ if (cutByActionDeadline) {
546
+ const error = new Error(
547
+ `ACTION_DEADLINE: bridge command ${String(args[0] ?? 'unknown')} was stopped after ${result.timeoutMs}ms when its action deadline ended (per-command limit ${configuredTimeoutMs}ms): ${command}`,
548
+ );
549
+ error.deadlineEpochMs = actionDeadline;
550
+ throw coded(error, MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE);
551
+ }
524
552
  throw coded(
525
- new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`),
553
+ new Error(
554
+ `Mobile CDP bridge command timed out after ${result.timeoutMs}ms (CDP_COMMAND_TIMEOUT: per-command limit ${configuredTimeoutMs}ms${Number.isFinite(actionDeadline) ? `, action budget had ${Math.max(0, Math.round(actionDeadline - Date.now()))}ms left` : ''}): ${command}`,
555
+ ),
526
556
  BRIDGE_ERROR_CODES.CDP_TIMEOUT,
527
557
  );
528
558
  }
@@ -628,6 +658,13 @@ export function isTargetTransition(error) {
628
658
  return TARGET_TRANSITION_CODES.has(error?.code);
629
659
  }
630
660
 
661
+ // A read a target transition interrupted, or the action deadline cut short,
662
+ // has no answer yet. Polling callers treat it as "not yet" so their own loop
663
+ // ends at the deadline with its own timeout message.
664
+ export function isUnansweredRead(error) {
665
+ return isTargetTransition(error) || error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE;
666
+ }
667
+
631
668
  function valueContains(actual, expected) {
632
669
  if (Array.isArray(expected)) {
633
670
  return Array.isArray(actual) &&
@@ -663,13 +700,152 @@ function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expec
663
700
  );
664
701
  }
665
702
 
703
+ export function inheritedDeadline(input) {
704
+ const deadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
705
+ return Number.isFinite(deadline) ? deadline : null;
706
+ }
707
+
708
+ export function earliestDeadline(input, deadline) {
709
+ const inherited = inheritedDeadline(input);
710
+ return inherited === null ? deadline : Math.min(deadline, inherited);
711
+ }
712
+
713
+ // The earlier of now + timeoutMs and the calling action's deadline. Route waits
714
+ // use it because past the action deadline bridgeCommand refuses to start
715
+ // get-route (ACTION_DEADLINE): polling on would only retry that refusal and
716
+ // could never see a route that did arrive.
717
+ export function deadlineFromTimeout(input, timeoutMs) {
718
+ return earliestDeadline(input, Date.now() + Math.max(0, Number(timeoutMs)));
719
+ }
720
+
721
+ // Default budgets by device class. A physical Android device over USB answers
722
+ // bridge calls in 0.1-0.5 s when idle and slower while the app is busy; a Perps
723
+ // market-details -> home navigation on a Pixel 6a took ~18 s. Emulators and
724
+ // simulators keep the shorter budgets so a real hang there still fails fast.
725
+ const MOBILE_BUDGET_DEFAULTS = Object.freeze({
726
+ virtual: { setupMs: 30_000, navigationMs: 15_000 },
727
+ physical: { setupMs: 60_000, navigationMs: 30_000 },
728
+ });
729
+ // Per bridge command inside a domain setup, so one hung command cannot spend
730
+ // the whole setup budget. Recipes override it with cdp_timeout_ms.
731
+ export const SETUP_COMMAND_TIMEOUT_MS = 15_000;
732
+
733
+ function positiveMs(value) {
734
+ const ms = Number(value);
735
+ return value !== null && value !== '' && Number.isFinite(ms) && ms > 0 ? ms : null;
736
+ }
737
+
738
+ // Env overrides (MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS,
739
+ // MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS) replace the device-class defaults;
740
+ // a node's own timeout_ms / navigation_timeout_ms still wins over both.
741
+ export function mobileBudgetDefaults(input) {
742
+ const env = { ...process.env, ...(input.context?.env || {}) };
743
+ const deviceClass = resolveMobileTarget(input).physicalAndroid ? 'physical' : 'virtual';
744
+ const defaults = MOBILE_BUDGET_DEFAULTS[deviceClass];
745
+ return {
746
+ deviceClass,
747
+ setupMs: positiveMs(env.MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS) ?? defaults.setupMs,
748
+ navigationMs:
749
+ positiveMs(env.MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS) ?? defaults.navigationMs,
750
+ };
751
+ }
752
+
753
+ export function mobileNavigationTimeoutMs(input) {
754
+ return positiveMs(input.node?.navigation_timeout_ms) ?? mobileBudgetDefaults(input).navigationMs;
755
+ }
756
+
757
+ export function mobileSetupTimeoutMs(input) {
758
+ return positiveMs(input.node?.timeout_ms) ?? mobileBudgetDefaults(input).setupMs;
759
+ }
760
+
761
+ // One overall budget for a multi-step domain setup (unlock, navigation, state
762
+ // convergence). Each phase runs on its own deadline: the rest of the setup
763
+ // budget, or less when sliceMs bounds it. Each bridge command inside a phase
764
+ // gets the per-command limit, capped by that phase deadline. A phase that runs
765
+ // out of time fails as DOMAIN_SETUP_DEADLINE naming the phase and both limits,
766
+ // not as a CDP timeout of whichever command happened to be running.
767
+ export function createSetupBudget(input, action) {
768
+ const { deviceClass } = mobileBudgetDefaults(input);
769
+ const navigationMs = mobileNavigationTimeoutMs(input);
770
+ const commandMs =
771
+ positiveMs(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms) ??
772
+ SETUP_COMMAND_TIMEOUT_MS;
773
+ const startedAt = Date.now();
774
+ const deadline = deadlineFromTimeout(input, mobileSetupTimeoutMs(input));
775
+ const limitMs = deadline - startedAt;
776
+
777
+ function deadlineError(phase, phaseStartedAt, phaseLimitMs, sliced, cause) {
778
+ const now = Date.now();
779
+ const budget = {
780
+ action,
781
+ phase,
782
+ deviceClass,
783
+ phaseElapsedMs: now - phaseStartedAt,
784
+ phaseLimitMs,
785
+ setupElapsedMs: now - startedAt,
786
+ setupLimitMs: limitMs,
787
+ };
788
+ const phaseText = cause === null
789
+ ? `phase '${phase}' could not start`
790
+ : `phase '${phase}' ran ${budget.phaseElapsedMs}ms of its ${phaseLimitMs}ms ${sliced ? 'slice' : 'share of the setup budget'}`;
791
+ const error = new Error(
792
+ `DOMAIN_SETUP_DEADLINE: ${action} ${phaseText}; setup ran ${budget.setupElapsedMs}ms of ${limitMs}ms (${deviceClass} device). ` +
793
+ `Raise timeout_ms (setup) or navigation_timeout_ms (navigation slice) on the node, or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS / MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS.` +
794
+ (cause === null ? '' : `\n Cause: ${String(cause?.message ?? cause)}`),
795
+ cause === null ? undefined : { cause },
796
+ );
797
+ error.budget = budget;
798
+ return coded(error, MOBILE_BUDGET_ERROR_CODES.DOMAIN_SETUP_DEADLINE);
799
+ }
800
+
801
+ return {
802
+ deadline,
803
+ limitMs,
804
+ navigationMs,
805
+ async phase(name, run, { sliceMs } = {}) {
806
+ const phaseStartedAt = Date.now();
807
+ const sliced = sliceMs !== undefined && phaseStartedAt + sliceMs < deadline;
808
+ const phaseDeadline = sliced ? phaseStartedAt + sliceMs : deadline;
809
+ const phaseLimitMs = phaseDeadline - phaseStartedAt;
810
+ if (phaseLimitMs <= 0) {
811
+ throw deadlineError(name, phaseStartedAt, phaseLimitMs, false, null);
812
+ }
813
+ const commandLimitMs = Math.min(commandMs, phaseLimitMs);
814
+ const phaseInput = {
815
+ ...input,
816
+ node: {
817
+ ...input.node,
818
+ [ACTION_DEADLINE_FIELD]: phaseDeadline,
819
+ timeout_ms: phaseLimitMs,
820
+ bridge_timeout_ms: commandLimitMs,
821
+ cdp_timeout_ms: commandLimitMs,
822
+ controller_ready_timeout_ms: phaseLimitMs,
823
+ navigation_timeout_ms: Math.min(navigationMs, phaseLimitMs),
824
+ },
825
+ };
826
+ try {
827
+ return await run(phaseInput);
828
+ } catch (error) {
829
+ if (error?.code === MOBILE_BUDGET_ERROR_CODES.DOMAIN_SETUP_DEADLINE) throw error;
830
+ // A nested wait with its own shorter deadline is not this phase running out.
831
+ const phaseRanOut =
832
+ Date.now() >= phaseDeadline ||
833
+ (error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE &&
834
+ error.deadlineEpochMs >= phaseDeadline);
835
+ if (!phaseRanOut) throw error;
836
+ throw deadlineError(name, phaseStartedAt, phaseLimitMs, sliced, error);
837
+ }
838
+ },
839
+ };
840
+ }
841
+
666
842
  async function waitForNextRouteProbe(deadline) {
667
843
  const remaining = deadline - Date.now();
668
844
  if (remaining > 0) await sleep(Math.min(250, remaining));
669
845
  }
670
846
 
671
847
  export async function navigate(input, route, params = {}, expectedRoute) {
672
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15000);
848
+ const timeoutMs = mobileNavigationTimeoutMs(input);
673
849
  let requestedRoute = String(expectedRoute ?? route);
674
850
  let requestedParams = requestedRoute === String(route) ? params : undefined;
675
851
  const nestedTarget = expectedRoute == null && typeof params?.screen === 'string';
@@ -712,7 +888,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
712
888
 
713
889
  while (navigateAttempts < 2 || recoveryPending) {
714
890
  if (recoveryPending) {
715
- const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
891
+ const deadline = recoveryDeadline ?? deadlineFromTimeout(input, timeoutMs);
716
892
  let currentRoute = null;
717
893
  while (Date.now() < deadline) {
718
894
  try {
@@ -798,7 +974,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
798
974
  if (!isTargetTransition(error)) throw error;
799
975
  lastTransitionError = error;
800
976
  transitionCodes.push(error.code);
801
- recoveryDeadline = Date.now() + timeoutMs;
977
+ recoveryDeadline = deadlineFromTimeout(input, timeoutMs);
802
978
  recoveryPending = true;
803
979
  await waitForNextRouteProbe(recoveryDeadline);
804
980
  }
@@ -820,7 +996,8 @@ export async function waitForRoute(
820
996
  expectedParams,
821
997
  ) {
822
998
  const expected = String(expectedRoute);
823
- const deadline = Date.now() + timeoutMs;
999
+ const startedAt = Date.now();
1000
+ const deadline = deadlineFromTimeout(input, timeoutMs);
824
1001
  // lastRoute is null when bridgeCommand returns null (transient: route not yet
825
1002
  // settled mid-navigation). Null means "not ready yet" — keep polling.
826
1003
  let lastRoute = null;
@@ -831,6 +1008,9 @@ export async function waitForRoute(
831
1008
  try {
832
1009
  lastRoute = await bridgeCommand(input, ['get-route']);
833
1010
  } catch (error) {
1011
+ // The action deadline cut the last poll short: report the route wait
1012
+ // (expected and last route) rather than that one poll.
1013
+ if (error?.code === MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE) break;
834
1014
  if (!isTargetTransition(error)) throw error;
835
1015
  lastTransitionError = error;
836
1016
  transitionCodes.push(error.code);
@@ -857,7 +1037,7 @@ export async function waitForRoute(
857
1037
  ? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
858
1038
  : JSON.stringify(lastRoute);
859
1039
  const timeoutError = new Error(
860
- `Timed out waiting for Mobile route '${expected}' after ${timeoutMs}ms (${pollCount} polls).\n` +
1040
+ `Timed out waiting for Mobile route '${expected}' after ${Date.now() - startedAt}ms (${pollCount} polls; limit ${deadline - startedAt}ms of ${timeoutMs}ms requested).\n` +
861
1041
  ` Expected route: ${expected}\n` +
862
1042
  ` Expected params: ${expectedParams === undefined ? 'any' : JSON.stringify(expectedParams)}\n` +
863
1043
  ` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
@@ -1,5 +1,10 @@
1
1
  // Open wallet home through WalletView, with a verified HomeNav fallback for older navigators.
2
- import { bridgeCommand, navigate, waitForRoute } from '../platform/bridge.mjs';
2
+ import {
3
+ bridgeCommand,
4
+ mobileNavigationTimeoutMs,
5
+ navigate,
6
+ waitForRoute,
7
+ } from '../platform/bridge.mjs';
3
8
 
4
9
  /**
5
10
  * Navigate to the wallet home across MetaMask Mobile navigation generations.
@@ -19,7 +24,7 @@ export async function navigateWalletHome(input) {
19
24
  const currentRoute = await waitForRoute(
20
25
  input,
21
26
  'WalletView',
22
- Number(input.node?.navigation_timeout_ms ?? 15000),
27
+ mobileNavigationTimeoutMs(input),
23
28
  );
24
29
  return {
25
30
  navigation: {
@@ -5,14 +5,18 @@ import { pathToFileURL } from 'node:url';
5
5
  import {
6
6
  bridgeCommand,
7
7
  MOBILE_BRIDGE_ERROR_CODES,
8
+ MOBILE_BUDGET_ERROR_CODES,
8
9
  runAdapter,
9
10
  } from '../platform/bridge.mjs';
10
11
 
11
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+ // A switch-account write cut short by the deadline may still have landed, so
14
+ // ACTION_DEADLINE leaves the outcome as unknown as a transport failure does.
12
15
  const TRANSIENT_READ_CODES = new Set([
13
16
  MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
14
17
  MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
15
18
  MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
19
+ MOBILE_BUDGET_ERROR_CODES.ACTION_DEADLINE,
16
20
  ]);
17
21
  const READ_ATTEMPT_TIMEOUT_MS = 10_000;
18
22
 
@@ -0,0 +1,8 @@
1
+ // The epoch ms by which a live adapter action must finish. The runner kills a
2
+ // live adapter process liveAdapterProcessTimeoutMs(node) after starting it.
3
+
4
+ import { liveAdapterProcessTimeoutMs } from '@farmslot/recipe-cli/harness';
5
+
6
+ export function liveAdapterDeadline(node, reserveMs = 5_000) {
7
+ return performance.timeOrigin + liveAdapterProcessTimeoutMs(node ?? {}) - reserveMs;
8
+ }
@@ -123,6 +123,25 @@ export function worstCaseNotionalUsd({ amount, leverage, price, szDecimals, maxS
123
123
  return (usd + step * Number(price)) * (1 + Number(maxSlippageBps) / 10_000);
124
124
  }
125
125
 
126
+ // The lot the controller submits for a market order of `amount` USDC margin (the
127
+ // same rounding as above), in whole size steps, its notional, and the margin one
128
+ // step adds at `price`. Null when the size decimals or price are unknown.
129
+ export function marketOrderLot({ amount, leverage, price, szDecimals }) {
130
+ const decimals = szDecimals == null || szDecimals === '' ? Number.NaN : Number(szDecimals);
131
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8 || !(Number(price) > 0)) return null;
132
+ const multiplier = 10 ** decimals;
133
+ const usd = Number(amount) * Number(leverage);
134
+ let lots = Math.round((usd / Number(price)) * multiplier);
135
+ if ((lots / multiplier) * Number(price) < usd) lots += 1;
136
+ if (!Number.isSafeInteger(lots) || lots <= 0) return null;
137
+ return {
138
+ sizeDecimals: decimals,
139
+ lots,
140
+ notionalBaseUnits: `${Math.ceil((lots / multiplier) * Number(price) * 1_000_000)}`,
141
+ stepMarginBaseUnits: `${Math.ceil((Number(price) / multiplier / Number(leverage)) * 1_000_000)}`,
142
+ };
143
+ }
144
+
126
145
  // Mainnet only: refuse before dispatch when the size the controller would submit
127
146
  // could fill above the house notional limit. Testnet needs no bound.
128
147
  export function requireHouseNotional({ network, symbol, amount, leverage, price, szDecimals, maxSlippageBps }) {
@@ -0,0 +1,93 @@
1
+ // Classify a Hyperliquid HTTP 429 (Too Many Requests) as RATE_LIMITED and retry
2
+ // Perps reads a bounded number of times with exponential backoff and jitter.
3
+ // Farm runs share one testnet account and egress IP, so a 429 is load on the
4
+ // venue, not a product result. Placements never retry here: see
5
+ // runGuardedExternalMutation, which retries only once the venue proves nothing
6
+ // was placed.
7
+
8
+ export const RATE_LIMITED = 'RATE_LIMITED';
9
+ export const RATE_LIMIT_RETRIES = 3;
10
+ const BASE_BACKOFF_MS = 1_000;
11
+
12
+ const TOO_MANY_REQUESTS = /too many requests|\b(?:http|status(?:\s*code)?)\s*:?\s*429\b/iu;
13
+
14
+ export function isRateLimited(error) {
15
+ if (error?.code === RATE_LIMITED) return true;
16
+ return TOO_MANY_REQUESTS.test(error instanceof Error ? error.message : String(error ?? ''));
17
+ }
18
+
19
+ // Retry n (1-based) waits between half and all of 1 s, 2 s, 4 s: at most 7 s over
20
+ // the three retries.
21
+ export function rateLimitBackoffMs(retry, random = Math.random) {
22
+ const ceiling = BASE_BACKOFF_MS * 2 ** (retry - 1);
23
+ return Math.round(ceiling / 2 + random() * (ceiling / 2));
24
+ }
25
+
26
+ // The sentence naming the call, its attempts and the backoff it used.
27
+ export function describeRateLimit({ call, attempts, waitedMs }) {
28
+ return `Hyperliquid answered 429 Too Many Requests to ${call} on ${attempts} attempt${attempts === 1 ? '' : 's'} `
29
+ + `after ${waitedMs} ms of backoff (exponential from ${BASE_BACKOFF_MS} ms with jitter, at most ${RATE_LIMIT_RETRIES} retries)`;
30
+ }
31
+
32
+ export function rateLimitedError({ call, attempts, waitedMs, reason = null, next = 'wait a minute and rerun' }) {
33
+ return Object.assign(
34
+ new Error(`${RATE_LIMITED}: ${describeRateLimit({ call, attempts, waitedMs })}${reason ? `; ${reason}` : ''}. Next: ${next}.`),
35
+ { code: RATE_LIMITED, call, attempts, waitedMs },
36
+ );
37
+ }
38
+
39
+ export function backoffSleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
43
+ // A retry that has not answered by the deadline fails there; the read itself
44
+ // keeps running, but the action no longer waits for it.
45
+ async function beforeDeadline(promise, deadline, timedOut) {
46
+ let timer;
47
+ try {
48
+ return await Promise.race([
49
+ promise,
50
+ new Promise((_, reject) => {
51
+ timer = setTimeout(() => reject(timedOut()), Math.max(0, deadline - Date.now()));
52
+ }),
53
+ ]);
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Run a read, retrying it after a 429 up to RATE_LIMIT_RETRIES times.
61
+ * @param call - The request named in the error, e.g. perpsGetPositions.
62
+ * @param read - The read to run; any other error is rethrown at once.
63
+ * @param options.deadline - Epoch ms the action must finish by. A retry starts only if its backoff
64
+ * and another attempt as long as the last one end before it, and fails RATE_LIMITED at it.
65
+ * @returns The read's result, or throws RATE_LIMITED once the retries or the deadline are spent.
66
+ */
67
+ export async function withRateLimitRetry(call, read, { deadline = null, sleep = backoffSleep, random = Math.random } = {}) {
68
+ let waitedMs = 0;
69
+ for (let attempt = 1; ; attempt += 1) {
70
+ const startedAt = Date.now();
71
+ try {
72
+ if (attempt === 1 || !Number.isFinite(deadline)) return await read();
73
+ return await beforeDeadline(read(), deadline, () => rateLimitedError({
74
+ call, attempts: attempt, waitedMs, reason: 'the last retry did not answer before the action deadline',
75
+ }));
76
+ } catch (error) {
77
+ // A nested read already spent its retries.
78
+ if (error?.code === RATE_LIMITED || !isRateLimited(error)) throw error;
79
+ if (attempt > RATE_LIMIT_RETRIES) {
80
+ throw rateLimitedError({ call, attempts: attempt, waitedMs });
81
+ }
82
+ const backoffMs = rateLimitBackoffMs(attempt, random);
83
+ if (Number.isFinite(deadline) && Date.now() + backoffMs + (Date.now() - startedAt) >= deadline) {
84
+ throw rateLimitedError({
85
+ call, attempts: attempt, waitedMs,
86
+ reason: `the next ${backoffMs} ms backoff and another attempt would pass the action deadline`,
87
+ });
88
+ }
89
+ await sleep(backoffMs);
90
+ waitedMs += backoffMs;
91
+ }
92
+ }
93
+ }
@@ -191,7 +191,7 @@ function visibleFeatures(items) {
191
191
  const long = has(/(?:long-cta-button|direction-long|market-details-long-button)$/u);
192
192
  const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
193
193
  return {
194
- activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
194
+ activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity|market-trades-row-))/u),
195
195
  balance: has(/^(?:perps-balance-dropdown|perps-balance-actions-available-value|perps-market-(?:balance-value|available-balance-text))/u),
196
196
  chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
197
197
  has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
@@ -283,7 +283,8 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
283
283
  options.maxItems,
284
284
  ),
285
285
  activity: compactEntries(
286
- selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-)/u.test(item.testId), options.maxItems),
286
+ // Mobile previews: Perps home Recent activity and the market's recent trades.
287
+ selected(unique, (item) => /^(?:transaction-card-|transaction-item(?:-|$)|perps-activity-row-|perps-(?:recent-activity|market-trades)-row-\d+$)/u.test(item.testId), options.maxItems),
287
288
  options.maxItems,
288
289
  ),
289
290
  orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
@@ -3788,7 +3788,7 @@
3788
3788
  ]
3789
3789
  },
3790
3790
  "metamask.perps.read_snapshot": {
3791
- "description": "Read a bounded, redacted source-dev snapshot of Extension Perps state through the optional product background hook.",
3791
+ "description": "Read a bounded, redacted source-dev snapshot of Extension Perps state through the optional product background hook. The history section is the funds ledger from perpsGetUserHistory (deposits, withdrawals and transfers): it holds no trades or orders, so zero history items does not mean no trading history. Read trade and order rows with metamask.perps.read_visible_state activity.",
3792
3792
  "schema": {
3793
3793
  "type": "object",
3794
3794
  "properties": {
@@ -3830,6 +3830,7 @@
3830
3830
  },
3831
3831
  "history": {
3832
3832
  "type": "object",
3833
+ "description": "Funds-ledger window (deposits, withdrawals and transfers) passed to perpsGetUserHistory; trades and orders are not part of it.",
3833
3834
  "properties": {
3834
3835
  "start_time_ms": {
3835
3836
  "type": "integer",
@@ -4465,7 +4466,7 @@
4465
4466
  "identity_rounding_tolerance_base_units": {
4466
4467
  "type": "string",
4467
4468
  "default": "10000",
4468
- "description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000."
4469
+ "description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000. A background market order's margin check adds one venue size step at the current price."
4469
4470
  }
4470
4471
  },
4471
4472
  "additionalProperties": false
@@ -930,7 +930,7 @@
930
930
  ]
931
931
  },
932
932
  "ui.wait_for": {
933
- "description": "Wait for a React Native testID/test_id or text target. expected=visible requires a measured viewport intersection; expected=present only requires the mounted tree.",
933
+ "description": "Wait for a React Native testID/test_id or text target. expected=visible requires a measured viewport intersection; expected=present only requires the mounted tree. expected=absent also passes when every mounted match is on a screen the navigator hides (an inactive tab, or a non-modal stack screen under a non-modal screen) and a native observation does not show the test ID.",
934
934
  "schema": {
935
935
  "type": "object",
936
936
  "properties": {
@@ -960,7 +960,8 @@
960
960
  "type": "boolean"
961
961
  },
962
962
  "timeout_ms": {
963
- "type": "number"
963
+ "type": "number",
964
+ "description": "Total wait in milliseconds (default 10000). The hidden-screen check for expected=absent needs about 3 s of it left for its native read."
964
965
  }
965
966
  },
966
967
  "additionalProperties": false
@@ -3544,7 +3545,7 @@
3544
3545
  "trading_state": {
3545
3546
  "type": "string",
3546
3547
  "enum": ["preserve", "clean_selected_testnet_market"],
3547
- "description": "High-level state policy. The clean policy removes matching positions, ordinary orders, TWAPs, and Chase sessions only on testnet and preserves them on mainnet."
3548
+ "description": "High-level state policy that sets positions, orders and strategies itself: preserve leaves them untouched, and the clean policy removes matching positions, ordinary orders, TWAPs, and Chase sessions only on testnet and preserves them on mainnet. Cannot be combined with an explicit positions, orders or strategies request other than false; omit it to apply such a request."
3548
3549
  },
3549
3550
  "positions": {
3550
3551
  "description": "Requested position state. Omit it or pass false to preserve current positions.",
@@ -3768,7 +3769,19 @@
3768
3769
  "additionalProperties": false
3769
3770
  },
3770
3771
  "timeout_ms": {
3771
- "type": "number"
3772
+ "type": "number",
3773
+ "minimum": 1,
3774
+ "description": "Overall budget for the whole start state setup: every phase runs inside it. Default 30000 on emulators and simulators, 60000 on a physical Android device (explicit non-emulator adb serial), or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS. A phase that runs out fails as DOMAIN_SETUP_DEADLINE naming the phase."
3775
+ },
3776
+ "navigation_timeout_ms": {
3777
+ "type": "number",
3778
+ "minimum": 1,
3779
+ "description": "Slice of the setup budget for each navigation phase. Default 15000 on emulators and simulators, 30000 on a physical Android device, or MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS."
3780
+ },
3781
+ "cdp_timeout_ms": {
3782
+ "type": "number",
3783
+ "minimum": 1,
3784
+ "description": "Limit for each bridge command inside the setup, capped by the phase deadline. Default 15000. A command that hits it fails as CDP_TIMEOUT (CDP_COMMAND_TIMEOUT in the message)."
3772
3785
  },
3773
3786
  "side": {
3774
3787
  "type": "string",
@@ -4104,7 +4117,19 @@
4104
4117
  "additionalProperties": false
4105
4118
  },
4106
4119
  "timeout_ms": {
4107
- "type": "number"
4120
+ "type": "number",
4121
+ "minimum": 1,
4122
+ "description": "Overall budget for the whole teardown: every phase runs inside it. Default 30000 on emulators and simulators, 60000 on a physical Android device (explicit non-emulator adb serial), or MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS. A phase that runs out fails as DOMAIN_SETUP_DEADLINE naming the phase."
4123
+ },
4124
+ "navigation_timeout_ms": {
4125
+ "type": "number",
4126
+ "minimum": 1,
4127
+ "description": "Slice of the setup budget for each navigation phase. Default 15000 on emulators and simulators, 30000 on a physical Android device, or MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS."
4128
+ },
4129
+ "cdp_timeout_ms": {
4130
+ "type": "number",
4131
+ "minimum": 1,
4132
+ "description": "Limit for each bridge command inside the setup, capped by the phase deadline. Default 15000. A command that hits it fails as CDP_TIMEOUT (CDP_COMMAND_TIMEOUT in the message)."
4108
4133
  },
4109
4134
  "side": {
4110
4135
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.86.1",
3
+ "version": "0.87.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -202,6 +202,7 @@
202
202
  "Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",
203
203
  "Mutation: require an independent state assertion and identity-bound receipt. Bind activity to that operation's accepted ID; an old row or nonzero count is insufficient. Opening and closing orders have different IDs. Schedule authorized cleanup in teardown, verify its result, and report any residue. Testnet cleanup closes what the task opened and never silently undoes another task's live proof on a reserved account or market.",
204
204
  "Testnet account state: on a testnet account or market the task owns for its run (the slot's fixture account, or one reserved with exclusive_testnet_market=true), converging is an explicit, ownership-scoped setup choice, not a violation. Use the team library's perps.ensure-market-state or perps.clean-market-testnet, or on Mobile and Core metamask.perps.ensure_positions / metamask.perps.ensure_orders (mode=all for strays), record what was closed in the trace, then prove. A pre-existing position or order on an account the task owns is never a reason to block, skip, or refuse, and a fixture that demands an empty account must converge rather than refuse. Extension ensure_* and teardown_state only assert; converge there through an explicitly funded leaf action. Never broaden cleanup onto an account or market the task does not own; obtain an owned one instead of blocking. On mainnet, orders follow the shared Perps order rule (PERPS_MAINNET_ORDER_REFUSED), Core needs CORE_PERPS_ALLOW_MAINNET_WRITES=1 for any write, Mobile cleanup acts on whatever network the app is on, and Extension refuses mainnet writes other than orders and `close_visible_position` in the fixture-policy lane.",
205
+ "Rate limits: RATE_LIMITED means Hyperliquid answered 429 Too Many Requests and bounded retries did not get through; it says nothing about the product. Wait a minute and rerun a read. A placement left outcome-unknown is never rerun before the market's positions, orders and fills are checked.",
205
206
  {
206
207
  "text": "An accepted order can fill before the UI updates. Preserve its receipt even when the expected resting-order assertion fails; one empty read does not prove cleanup. Check the read API's cache and freshness options before adding polling. Reconcile returned margin against the pre-placement balance. A successful close does not turn the original failed proof into a pass.",
207
208
  "adapters": [