@deeeed/metamask-harness 0.86.2 → 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 (35) hide show
  1. package/CHANGELOG.md +26 -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/compare_provider_market.mjs +12 -8
  20. package/library/actions/extension/perps/mutation-receipt.mjs +120 -23
  21. package/library/actions/extension/perps/perps.mjs +98 -30
  22. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  23. package/library/actions/extension/platform/cdp.mjs +6 -2
  24. package/library/actions/extension/ui/navigate.mjs +8 -0
  25. package/library/actions/mobile/perps/perps.mjs +106 -92
  26. package/library/actions/mobile/platform/bridge.mjs +171 -10
  27. package/library/actions/mobile/wallet/home.mjs +7 -2
  28. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  29. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  30. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  31. package/library/actions/shared/perps/visible-state.mjs +3 -2
  32. package/library/manifests/extension.action-manifest.json +2 -1
  33. package/library/manifests/mobile.action-manifest.json +30 -5
  34. package/package.json +1 -1
  35. package/site/assets/help-recipes.json +1 -0
@@ -19,6 +19,14 @@ import path from 'node:path';
19
19
 
20
20
  import { isFixtureAccount } from '../../shared/wallet/fixture-accounts.mjs';
21
21
  import { MAINNET_HOUSE_LIMITS, orderNetwork, requirePerpsOrderAllowed } from '../../shared/perps/order-policy.mjs';
22
+ import {
23
+ RATE_LIMIT_RETRIES,
24
+ RATE_LIMITED,
25
+ backoffSleep,
26
+ describeRateLimit,
27
+ isRateLimited,
28
+ rateLimitBackoffMs,
29
+ } from '../../shared/perps/rate-limit.mjs';
22
30
  import { readWalletStateExpression } from '../wallet/state.mjs';
23
31
  import { readPerpsRuntimeStateExpression } from './state.mjs';
24
32
 
@@ -43,6 +51,14 @@ function recoveryError(receipt, detail) {
43
51
  );
44
52
  }
45
53
 
54
+ // The outcome stays unknown, so the Next is still the receipt's, never a plain rerun.
55
+ function rateLimitedRecoveryError(receipt, rateLimit, reason) {
56
+ return Object.assign(
57
+ new Error(`${RATE_LIMITED}: ${recoveryError(receipt, `${describeRateLimit(rateLimit)}; ${reason}`).message}`),
58
+ { code: RATE_LIMITED },
59
+ );
60
+ }
61
+
46
62
  class ExternalMutationNotDispatchedError extends Error {}
47
63
 
48
64
  export function externalMutationNotDispatched(message) {
@@ -848,9 +864,9 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
848
864
  || !validFilledOrder(filledSize, averagePrice)) {
849
865
  throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
850
866
  }
851
- return writeSubmissionAcceptance(input, receipt, {
867
+ return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
852
868
  source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
853
- });
869
+ } }, 'submission acceptance');
854
870
  }
855
871
  const closing = receipt.record.identity.operation === 'perps-close-position';
856
872
  const market = closing || receipt.record.identity.operation === 'perps-market-order';
@@ -871,19 +887,25 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
871
887
  if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
872
888
  throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
873
889
  }
874
- return writeSubmissionAcceptance(input, receipt, {
890
+ return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
875
891
  venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
876
892
  ...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
877
- });
893
+ } }, 'submission acceptance');
878
894
  }
879
895
 
880
- async function writeSubmissionAcceptance(input, receipt, acceptance) {
896
+ // Adds fields to a still outcome-unknown receipt: the accepted order, or a 429
897
+ // and the venue read that followed it.
898
+ async function amendOutcomeUnknownReceipt(input, receipt, fields, label) {
881
899
  const destination = await receiptDestination(input);
882
900
  const current = await readPublished(destination);
883
901
  if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
884
- throw recoveryError(receipt, 'the receipt changed before submission acceptance was recorded');
902
+ throw recoveryError(receipt, `the receipt changed before ${label} was recorded`);
903
+ }
904
+ const record = { ...current.record, ...fields };
905
+ const bytes = canonical(record);
906
+ if (bytes.length > 12_288 || /secret|password|srp|mnemonic|private.?key/iu.test(bytes)) {
907
+ throw recoveryError(receipt, `the ${label} receipt is unsafe`);
885
908
  }
886
- const record = { ...current.record, acceptance };
887
909
  const staged = await stagedRecord(destination.directory, record);
888
910
  try {
889
911
  await rename(staged.stagedPath, destination.absolute);
@@ -893,12 +915,21 @@ async function writeSubmissionAcceptance(input, receipt, acceptance) {
893
915
  }
894
916
  const published = await readPublished(destination);
895
917
  if (!published || canonical(published.record) !== canonical(record)) {
896
- throw recoveryError(receipt, 'submission acceptance could not be reread');
918
+ throw recoveryError(receipt, `${label} could not be reread`);
897
919
  }
898
920
  return { ...published, resumed: receipt.resumed };
899
921
  }
900
922
 
901
- export async function runGuardedExternalMutation({ input, identity, dispatch, observe, publicationFacts, receiptIo }) {
923
+ /**
924
+ * Publish the receipt, dispatch once, observe the postcondition and complete the receipt.
925
+ * @param rateLimit - Optional retry of a dispatch the venue refused with a 429:
926
+ * { call, deadline, verifyBudgetMs, verifyNotPlaced({ sinceMs }) -> { proven, reason?, facts? }, sleep?, random? }.
927
+ * A retry runs only when the dispatch itself threw the 429 (nothing accepted), the backoff and
928
+ * the venue read fit before the deadline, and verifyNotPlaced then proves from the venue that
929
+ * nothing was placed since the first dispatch. Every 429 is recorded in the receipt with its
930
+ * verification or the reason it was not retried.
931
+ */
932
+ export async function runGuardedExternalMutation({ input, identity, dispatch, observe, publicationFacts, receiptIo, rateLimit = null }) {
902
933
  let receipt = await beginExternalMutation(input, identity, receiptIo, publicationFacts);
903
934
  if (receipt.record?.state === 'observed-policy-violation') {
904
935
  throw new Error('External mutation receipt records an observed policy violation and is cleanup-only.');
@@ -907,27 +938,93 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
907
938
  throw new Error('External mutation receipt state is unsafe.');
908
939
  }
909
940
  if (!receipt.resumed) {
910
- try {
911
- const acceptance = await dispatch();
912
- if (acceptance?.venueOrderId) receipt = await recordSubmissionAcceptance(input, receipt, acceptance);
913
- } catch (error) {
914
- if (error instanceof ExternalMutationNotDispatchedError) {
915
- await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
916
- throw Object.assign(
917
- new Error(
918
- `External mutation was safely not dispatched (${error.message}). ` +
919
- 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
920
- ),
921
- { knownNotDispatched: true },
922
- );
941
+ const call = rateLimit?.call ?? `${identity.action} dispatch`;
942
+ const sinceMs = Date.now();
943
+ let waitedMs = 0;
944
+ for (let attempt = 1; ; attempt += 1) {
945
+ let returned = false;
946
+ try {
947
+ // A retry must reach the venue before the deadline, its pre-dispatch checks included.
948
+ const acceptance = await dispatch(attempt > 1 ? { deadline: rateLimit.deadline } : {});
949
+ returned = true;
950
+ if (acceptance?.venueOrderId) receipt = await recordSubmissionAcceptance(input, receipt, acceptance);
951
+ break;
952
+ } catch (error) {
953
+ if (error instanceof ExternalMutationNotDispatchedError) {
954
+ await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
955
+ // A retry that was not sent still ends the action on the 429 that called for it.
956
+ const retried = attempt > 1;
957
+ throw Object.assign(
958
+ new Error(
959
+ `${retried ? `${RATE_LIMITED}: ` : ''}External mutation was safely not dispatched (` +
960
+ `${retried ? `${describeRateLimit({ call, attempts: attempt - 1, waitedMs })}; the retry was not sent: ` : ''}` +
961
+ `${error.message}). ` +
962
+ 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
963
+ ),
964
+ { knownNotDispatched: true, ...(retried ? { code: RATE_LIMITED } : {}) },
965
+ );
966
+ }
967
+ const message = error instanceof Error ? error.message : String(error);
968
+ if (returned || !isRateLimited(error)) {
969
+ throw recoveryError(receipt, `pointer dispatch may have occurred (${message})`);
970
+ }
971
+ const deadline = rateLimit?.deadline;
972
+ const passesDeadline = (ms) => Number.isFinite(deadline) && Date.now() + ms >= deadline;
973
+ const backoffMs = attempt > RATE_LIMIT_RETRIES ? 0 : rateLimitBackoffMs(attempt, rateLimit?.random);
974
+ let refusal = !rateLimit?.verifyNotPlaced ? 'this mutation has no venue read that can prove nothing was placed'
975
+ : receipt.record.acceptance ? 'the receipt already records an accepted order'
976
+ : attempt > RATE_LIMIT_RETRIES ? 'its retries are spent'
977
+ : passesDeadline(backoffMs + (rateLimit.verifyBudgetMs ?? 0))
978
+ ? `the next ${backoffMs} ms backoff and the venue read would pass the action deadline` : null;
979
+ let verification = null;
980
+ if (!refusal) {
981
+ await (rateLimit.sleep ?? backoffSleep)(backoffMs);
982
+ waitedMs += backoffMs;
983
+ try {
984
+ verification = await rateLimit.verifyNotPlaced({ sinceMs });
985
+ } catch (verifyError) {
986
+ verification = {
987
+ proven: false,
988
+ reason: `the venue read failed (${verifyError instanceof Error ? verifyError.message : String(verifyError)})`,
989
+ };
990
+ }
991
+ if (verification?.proven !== true) {
992
+ refusal = verification?.reason ?? 'the venue read did not prove that nothing was placed';
993
+ } else if (passesDeadline(0)) {
994
+ refusal = 'the venue read ended past the action deadline';
995
+ }
996
+ }
997
+ receipt = await amendOutcomeUnknownReceipt(input, receipt, {
998
+ rateLimitedDispatches: [...(receipt.record.rateLimitedDispatches ?? []), {
999
+ attempt,
1000
+ error: message.slice(0, 200),
1001
+ ...(verification ? {
1002
+ backoffMs,
1003
+ verification: {
1004
+ proven: verification.proven === true,
1005
+ ...(verification.facts ? { facts: verification.facts } : {}),
1006
+ },
1007
+ } : {}),
1008
+ ...(refusal ? { refusal: String(refusal).slice(0, 500) } : {}),
1009
+ }],
1010
+ }, 'the rate-limited dispatch');
1011
+ if (refusal) {
1012
+ throw rateLimitedRecoveryError(receipt, { call, attempts: attempt, waitedMs }, receipt.record.rateLimitedDispatches.at(-1).refusal);
1013
+ }
923
1014
  }
924
- throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
925
1015
  }
926
1016
  }
927
1017
  let observation;
928
1018
  try {
929
1019
  observation = await observe({ resumed: receipt.resumed, receipt: receipt.record });
930
1020
  } catch (error) {
1021
+ if (isRateLimited(error)) {
1022
+ throw rateLimitedRecoveryError(receipt, {
1023
+ call: error.call ?? 'the postcondition read',
1024
+ attempts: error.attempts ?? 1,
1025
+ waitedMs: error.waitedMs ?? 0,
1026
+ }, 'the postcondition read could not run, so the dispatched order is not proven');
1027
+ }
931
1028
  throw recoveryError(receipt, error instanceof Error ? error.message : String(error));
932
1029
  }
933
1030
  if (!observation || observation.success !== true) {
@@ -31,6 +31,8 @@ import {
31
31
  marketOrderLot,
32
32
  requireHouseNotional,
33
33
  } from '../../shared/perps/order-policy.mjs';
34
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
35
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
34
36
  import {
35
37
  orderIdentityMatches,
36
38
  parseOrderCard,
@@ -345,23 +347,36 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
345
347
  };
346
348
  }
347
349
 
348
- async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
349
- const accepted = placement.record?.acceptance;
350
- if (!accepted?.venueOrderId) return null;
350
+ function hyperliquidOrigin(network) {
351
+ return network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
352
+ }
353
+
354
+ function postHyperliquidInfo(network, body, signal = AbortSignal.timeout(10_000)) {
355
+ return fetch(`${hyperliquidOrigin(network)}/info`, {
356
+ method: 'POST', headers: { 'content-type': 'application/json' },
357
+ body: JSON.stringify(body), signal,
358
+ });
359
+ }
360
+
361
+ // The Hyperliquid network and user a receipt's order went to, read from the live
362
+ // app and bound to the receipt's selected-account hash.
363
+ async function receiptVenueUser(input, page, identity, purpose) {
351
364
  const { provider, network } = await requirePerpsTradingContext(input, page);
352
- if (provider !== 'hyperliquid') throw new Error('Accepted-order status requires the original Hyperliquid provider.\nNext: mm-harness last --json');
365
+ if (provider !== 'hyperliquid') throw new Error(`${purpose} requires the original Hyperliquid provider.\nNext: mm-harness last --json`);
353
366
  const wallet = await page.evaluate(readWalletStateExpression());
354
367
  const user = String(wallet.selectedAccount?.address ?? '').toLowerCase();
355
368
  if (!/^0x[a-f0-9]{40}$/u.test(user)
356
- || createHash('sha256').update(user).digest('hex') !== placement.record.identity.selectedIdentitySha256) {
357
- throw new Error('Accepted-order status wallet differs from the placement receipt.\nNext: mm-harness last --json');
369
+ || createHash('sha256').update(user).digest('hex') !== identity.selectedIdentitySha256) {
370
+ throw new Error(`${purpose} wallet differs from the placement receipt.\nNext: mm-harness last --json`);
358
371
  }
359
- const origin = network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
360
- const response = await fetch(`${origin}/info`, {
361
- method: 'POST', headers: { 'content-type': 'application/json' },
362
- body: JSON.stringify({ type: 'orderStatus', user, oid: Number(accepted.venueOrderId) }),
363
- signal: AbortSignal.timeout(10_000),
364
- });
372
+ return { network, user };
373
+ }
374
+
375
+ async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
376
+ const accepted = placement.record?.acceptance;
377
+ if (!accepted?.venueOrderId) return null;
378
+ const { network, user } = await receiptVenueUser(input, page, placement.record.identity, 'Accepted-order status');
379
+ const response = await postHyperliquidInfo(network, { type: 'orderStatus', user, oid: Number(accepted.venueOrderId) });
365
380
  if (!response.ok) throw new Error('Accepted-order status request failed.\nNext: mm-harness last --json');
366
381
  const result = await response.json();
367
382
  if (result.status === 'unknownOid') throw new Error('Accepted venue order status is unavailable.\nNext: mm-harness last --json');
@@ -671,7 +686,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
671
686
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
672
687
  throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
673
688
  }
674
- const price = await page.evaluate(String.raw`(async () => {
689
+ const price = await withRateLimitRetry('perpsGetMarketDataWithPrices', () => page.evaluate(String.raw`(async () => {
675
690
  const request = globalThis.stateHooks?.submitRequestToBackground;
676
691
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
677
692
  const symbol = ${JSON.stringify(symbol)};
@@ -709,7 +724,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
709
724
  const line = body.split('\n').find((text) => /\$[0-9][0-9,]*(?:\.[0-9]+)?/.test(text));
710
725
  const fromBody = parsePrice(line);
711
726
  return fromBody ? { price: fromBody, source: 'document-body-price-line' } : null;
712
- })()`);
727
+ })()`), { deadline: page.actionDeadline });
713
728
  const numericPrice = Number(price?.price ?? price);
714
729
  if (!Number.isFinite(numericPrice) || numericPrice <= 0) throw new Error(`Unable to determine current Perps price for ${symbol}.`);
715
730
  return numericPrice;
@@ -915,7 +930,7 @@ async function dispatchBackgroundPlace(page, {
915
930
  }
916
931
 
917
932
  async function readOpenOrdersState(page) {
918
- const state = await page.evaluate(`(async () => {
933
+ const state = await withRateLimitRetry('perpsGetOpenOrders', () => page.evaluate(`(async () => {
919
934
  const request = globalThis.stateHooks?.submitRequestToBackground;
920
935
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
921
936
  const cached = manager?.orders?.cache;
@@ -933,13 +948,13 @@ async function readOpenOrdersState(page) {
933
948
  connected: Boolean(manager?.orders?.isConnected),
934
949
  orders: Array.isArray(cached) ? cached : [],
935
950
  };
936
- })()`);
951
+ })()`), { deadline: page.actionDeadline });
937
952
  if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
938
953
  return state;
939
954
  }
940
955
 
941
956
  async function readAccountState(page) {
942
- const state = await page.evaluate(`(async () => {
957
+ const state = await withRateLimitRetry('perpsGetAccountState', () => page.evaluate(`(async () => {
943
958
  const request = globalThis.stateHooks?.submitRequestToBackground;
944
959
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
945
960
  const cached = manager?.account?.cache;
@@ -957,7 +972,7 @@ async function readAccountState(page) {
957
972
  connected: Boolean(manager?.account?.isConnected),
958
973
  account: cached && typeof cached === 'object' ? cached : null,
959
974
  };
960
- })()`);
975
+ })()`), { deadline: page.actionDeadline });
961
976
  if (!state.available) throw new Error('Perps account state is unavailable from both background and stream manager cache.');
962
977
  return state.account;
963
978
  }
@@ -1242,6 +1257,52 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
1242
1257
  throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
1243
1258
  }
1244
1259
 
1260
+ // Fills and open orders up to this long before the first dispatch count as this
1261
+ // order's, so a local clock behind the venue's cannot hide one.
1262
+ const VENUE_CLOCK_SKEW_MS = 30_000;
1263
+ // All three venue reads together; a retry starts only if they fit before its deadline.
1264
+ const VENUE_VERIFY_TIMEOUT_MS = 5_000;
1265
+
1266
+ // After a 429 on a background placement: Hyperliquid's own account state, not the
1267
+ // extension's cache, shows no open order and no fill in the market since the first
1268
+ // dispatch, and for a market order no position on its side (the pre-order baseline
1269
+ // had none). Open orders are read before fills, so an order of ours that fills
1270
+ // between the two reads still shows in one of them. Anything else, or a failed
1271
+ // read, leaves the outcome unproven.
1272
+ async function verifyNothingPlacedOnVenue(input, page, identity, sinceMs) {
1273
+ const coin = identity.market;
1274
+ if (coin.includes(':')) {
1275
+ return { proven: false, reason: `the venue read covers only the main Hyperliquid dex, not ${coin}` };
1276
+ }
1277
+ const { network, user } = await receiptVenueUser(input, page, identity, 'Rate-limit verification');
1278
+ const startTime = sinceMs - VENUE_CLOCK_SKEW_MS;
1279
+ const signal = AbortSignal.timeout(VENUE_VERIFY_TIMEOUT_MS);
1280
+ const read = async (body) => {
1281
+ const response = await postHyperliquidInfo(network, body, signal);
1282
+ if (!response.ok) throw new Error(`Hyperliquid ${body.type} answered HTTP ${response.status}`);
1283
+ return response.json();
1284
+ };
1285
+ const orders = await read({ type: 'openOrders', user });
1286
+ const fills = await read({ type: 'userFillsByTime', user, startTime });
1287
+ const account = await read({ type: 'clearinghouseState', user });
1288
+ if (!Array.isArray(fills) || !Array.isArray(orders) || !Array.isArray(account?.assetPositions)) {
1289
+ return { proven: false, reason: 'the Hyperliquid account reads returned an unexpected shape' };
1290
+ }
1291
+ const size = Number(account.assetPositions.find((entry) => entry?.position?.coin === coin)?.position?.szi ?? 0);
1292
+ if (!Number.isFinite(size)) return { proven: false, reason: `the Hyperliquid ${coin} position size is unreadable` };
1293
+ const facts = {
1294
+ source: 'hyperliquid-info',
1295
+ startTime,
1296
+ openOrders: orders.filter((order) => order?.coin === coin && !(Number(order?.timestamp) < startTime)).length,
1297
+ fills: fills.filter((fill) => fill?.coin === coin && !(Number(fill?.time) < startTime)).length,
1298
+ sameSidePosition: identity.orderType === 'market' && (identity.side === 'long' ? size > 0 : size < 0),
1299
+ };
1300
+ if (facts.openOrders > 0 || facts.fills > 0 || facts.sameSidePosition) {
1301
+ return { proven: false, reason: `Hyperliquid shows ${coin} activity since the first dispatch`, facts };
1302
+ }
1303
+ return { proven: true, facts };
1304
+ }
1305
+
1245
1306
  async function placeOrderThroughBackground(input, page, {
1246
1307
  amount,
1247
1308
  bound = null,
@@ -1296,6 +1357,13 @@ async function placeOrderThroughBackground(input, page, {
1296
1357
  const mutation = await runGuardedExternalMutation({
1297
1358
  input,
1298
1359
  identity,
1360
+ rateLimit: {
1361
+ call: 'perpsPlaceOrder',
1362
+ // A retry leaves the postcondition its timeout_ms and 5 s before the runner's process limit.
1363
+ deadline: liveAdapterDeadline(input.node, Number(input.node?.timeout_ms ?? 30_000) + 5_000),
1364
+ verifyBudgetMs: VENUE_VERIFY_TIMEOUT_MS,
1365
+ verifyNotPlaced: ({ sinceMs }) => verifyNothingPlacedOnVenue(input, page, identity, sinceMs),
1366
+ },
1299
1367
  // The pre-order balance lets a receipt-bound close reconcile its final balance.
1300
1368
  publicationFacts: {
1301
1369
  balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
@@ -1303,16 +1371,20 @@ async function placeOrderThroughBackground(input, page, {
1303
1371
  fundingBalance,
1304
1372
  ...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
1305
1373
  },
1306
- dispatch: async () => {
1374
+ dispatch: async ({ deadline = null } = {}) => {
1307
1375
  try {
1376
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
1308
1377
  await requireCurrentVisibleFundingBalance(
1309
1378
  input,
1310
1379
  page,
1311
- Number(input.node?.timeout_ms ?? 30_000),
1380
+ deadline == null ? timeoutMs : Math.max(0, Math.min(timeoutMs, deadline - Date.now())),
1312
1381
  fundingBalance.observedBaseUnits,
1313
1382
  );
1314
1383
  await requirePerpsTradingContext(input, page);
1315
1384
  assertTrustedExternalMutationTarget(input, page, identity);
1385
+ if (deadline != null && Date.now() >= deadline) {
1386
+ throw new Error('it reached the action deadline before dispatch');
1387
+ }
1316
1388
  } catch (error) {
1317
1389
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
1318
1390
  }
@@ -1567,12 +1639,8 @@ export async function observeHyperliquidSubmission(page, expected) {
1567
1639
  if (expected.provider !== 'hyperliquid' || !['market', 'limit'].includes(expected.orderType)) {
1568
1640
  throw new Error('Exact visible submission observation requires a Hyperliquid market or limit order.');
1569
1641
  }
1570
- const origin = expected.network === 'testnet'
1571
- ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
1572
- const metadataResponse = await fetch(`${origin}/info`, {
1573
- method: 'POST', headers: { 'content-type': 'application/json' },
1574
- body: JSON.stringify({ type: 'meta' }), signal: AbortSignal.timeout(10_000),
1575
- });
1642
+ const origin = hyperliquidOrigin(expected.network);
1643
+ const metadataResponse = await postHyperliquidInfo(expected.network, { type: 'meta' });
1576
1644
  if (!metadataResponse.ok) throw new Error('Hyperliquid market metadata is unavailable before submission.');
1577
1645
  const metadata = await metadataResponse.json();
1578
1646
  const asset = metadata.universe?.findIndex((market) => market.name === expected.market);
@@ -2178,7 +2246,7 @@ export async function placeOrder(input) {
2178
2246
  // read in one evaluation with the context guard before, between and after the
2179
2247
  // reads, so a switch to another account's book and back can't pass as flat.
2180
2248
  async function readBoundBook(page, bound) {
2181
- const book = await page.evaluate(`(async () => {
2249
+ const book = await withRateLimitRetry('perpsGetPositions/perpsGetOpenOrders', () => page.evaluate(`(async () => {
2182
2250
  const request = globalThis.stateHooks?.submitRequestToBackground;
2183
2251
  if (typeof request !== 'function') return { unavailable: true };
2184
2252
  const guard = async () => { ${boundContextGuard(bound)} return null; };
@@ -2188,7 +2256,7 @@ async function readBoundBook(page, bound) {
2188
2256
  const orders = await request('perpsGetOpenOrders', []);
2189
2257
  if (await guard()) return { changed: true };
2190
2258
  return { positions, orders };
2191
- })()`);
2259
+ })()`), { deadline: page.actionDeadline });
2192
2260
  if (book?.changed) {
2193
2261
  throw Object.assign(
2194
2262
  new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
@@ -2202,9 +2270,9 @@ async function readBoundBook(page, bound) {
2202
2270
  }
2203
2271
 
2204
2272
  async function readSizeDecimals(page, symbol) {
2205
- const markets = await page.evaluate(
2273
+ const markets = await withRateLimitRetry('perpsGetMarkets', () => page.evaluate(
2206
2274
  `globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
2207
- );
2275
+ ), { deadline: page.actionDeadline });
2208
2276
  const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
2209
2277
  return market?.szDecimals ?? null;
2210
2278
  }
@@ -3,6 +3,7 @@
3
3
  import { pathToFileURL } from 'node:url';
4
4
 
5
5
  import { normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
6
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
6
7
 
7
8
  const ALL_SECTIONS = ['account', 'markets', 'positions', 'orders', 'history', 'watchlist'];
8
9
  const DEFAULT_HISTORY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
@@ -533,8 +534,11 @@ export async function readSnapshot(input, testOptions = {}) {
533
534
  const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
534
535
  const samples = [];
535
536
  for (let index = 0; index < options.sampling.count; index += 1) {
536
- const raw = await page.evaluate(snapshotRequestExpression(options));
537
- samples.push(normalizeSnapshotResult(raw, options));
537
+ // A section a 429 left unavailable fails normalization; the sample reruns.
538
+ samples.push(await withRateLimitRetry('Perps snapshot', async () => normalizeSnapshotResult(
539
+ await page.evaluate(snapshotRequestExpression(options)),
540
+ options,
541
+ ), { sleep: pause, deadline: page.actionDeadline }));
538
542
  if (index + 1 < options.sampling.count) await pause(options.sampling.intervalMs);
539
543
  }
540
544
  const sections = samples.at(-1);
@@ -22,6 +22,8 @@ import path from 'node:path';
22
22
 
23
23
  import { captureActiveRecipeRecordingSnapshot, recipeHarnessPath } from '@farmslot/recipe-cli/harness';
24
24
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, extensionIdPath, walletFixturePath } from '../../harness-exports.mjs';
25
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
26
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
25
27
 
26
28
  // Resolve the Farmslot harness through normal package dependencies by default.
27
29
  // Local Farmslot source is only a dev override handled by the harness paths module.
@@ -843,6 +845,8 @@ export async function withExtensionPage(input, callback) {
843
845
  const origin = `chrome-extension://${extensionId}`;
844
846
  const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
845
847
  const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
848
+ // Rate-limit retries on this page's reads stop before the runner's process limit.
849
+ page.actionDeadline = liveAdapterDeadline(input.node);
846
850
  const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
847
851
  if (recoverVisibleWallet) {
848
852
  try {
@@ -1030,7 +1034,7 @@ export class ExtensionPage extends CdpWebPage {
1030
1034
  }
1031
1035
 
1032
1036
  async readPositions() {
1033
- return this.evaluate(`(async () => {
1037
+ return withRateLimitRetry('perpsGetPositions', () => this.evaluate(`(async () => {
1034
1038
  const request = globalThis.stateHooks?.submitRequestToBackground;
1035
1039
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
1036
1040
  const cached = manager?.positions?.cache;
@@ -1050,7 +1054,7 @@ export class ExtensionPage extends CdpWebPage {
1050
1054
  connected: Boolean(manager?.positions?.isConnected),
1051
1055
  positions: Array.isArray(cached) ? cached : [],
1052
1056
  };
1053
- })()`);
1057
+ })()`), { deadline: this.actionDeadline });
1054
1058
  }
1055
1059
 
1056
1060
  async screenshot(contextOrInput, relPath, metadata = {}) {
@@ -280,6 +280,8 @@ export async function openPerpsHome(page, timeoutMs) {
280
280
  await pauseForUi(page);
281
281
  }
282
282
 
283
+ // The sample is capped at 30 and the header and tabs come first in the DOM, so loading
284
+ // markers are listed first; otherwise a Perps view still loading reads as a wrong route.
283
285
  const state = await page.evaluate(`({
284
286
  href: location.href,
285
287
  title: document.title,
@@ -291,8 +293,14 @@ export async function openPerpsHome(page, timeoutMs) {
291
293
  })
292
294
  .map((element) => element.getAttribute('data-testid'))
293
295
  .filter(Boolean)
296
+ .sort((a, b) => Number(b.endsWith('-loading')) - Number(a.endsWith('-loading')))
294
297
  .slice(0, 30),
295
298
  })`);
299
+ if (await hasVisibleSelector(page, perpsLoading)) {
300
+ throw new Error(
301
+ `extension ui.navigate page=perps: Perps home stayed on its loading view (perps-view-loading visible) within the ${timeoutMs}ms budget; observed ${JSON.stringify(state)}.\nNext: check the Perps stream/provider, or raise timeout_ms; a raw hash route does not wait for Perps readiness.`,
302
+ );
303
+ }
296
304
  throw new Error(
297
305
  `extension ui.navigate page=perps could not resolve the Perps home through visible UI; observed ${JSON.stringify(state)}. Capture the current UI with mm-harness call ui.screenshot, then use a verified visible control or raw hash/path.`,
298
306
  );