@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
@@ -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) {
@@ -831,8 +847,27 @@ export async function recordExternalMutationCleanupCandidate(input, receipt, can
831
847
  });
832
848
  }
833
849
 
850
+ // A market order placed through the background Perps request reports the venue's
851
+ // filled order itself; there is no observed network request to bind.
852
+ export const BACKGROUND_ACCEPTANCE_SOURCE = 'background-perpsPlaceOrder';
853
+
854
+ function validFilledOrder(filledSize, averagePrice) {
855
+ return /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,8})?$/u.test(String(filledSize)) && Number(filledSize) > 0
856
+ && /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$/u.test(String(averagePrice)) && Number(averagePrice) > 0;
857
+ }
858
+
834
859
  async function recordSubmissionAcceptance(input, receipt, acceptance) {
835
860
  const { venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits, filledSize, averagePrice } = acceptance;
861
+ if (acceptance.source === BACKGROUND_ACCEPTANCE_SOURCE) {
862
+ if (receipt.record.identity.operation !== 'perps-market-order' || receipt.record.identity.provider !== 'hyperliquid'
863
+ || !/^[1-9][0-9]{0,19}$/u.test(String(venueOrderId)) || status !== 'filled' || size !== filledSize
864
+ || !validFilledOrder(filledSize, averagePrice)) {
865
+ throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
866
+ }
867
+ return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
868
+ source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
869
+ } }, 'submission acceptance');
870
+ }
836
871
  const closing = receipt.record.identity.operation === 'perps-close-position';
837
872
  const market = closing || receipt.record.identity.operation === 'perps-market-order';
838
873
  const sizing = closing ? receipt.record.facts?.closeSizing
@@ -849,20 +884,28 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
849
884
  || !BASE_UNITS.test(String(limitPriceBaseUnits))) {
850
885
  throw recoveryError(receipt, 'submission acceptance has invalid order identity');
851
886
  }
852
- if (market && status === 'filled' && (!/^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,8})?$/u.test(String(filledSize))
853
- || !(Number(filledSize) > 0) || Number(filledSize) > Number(size)
854
- || !/^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$/u.test(String(averagePrice)) || !(Number(averagePrice) > 0))) {
887
+ if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
855
888
  throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
856
889
  }
890
+ return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
891
+ venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
892
+ ...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
893
+ } }, 'submission acceptance');
894
+ }
895
+
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) {
857
899
  const destination = await receiptDestination(input);
858
900
  const current = await readPublished(destination);
859
901
  if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
860
- 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`);
861
908
  }
862
- const record = { ...current.record, acceptance: {
863
- venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
864
- ...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
865
- } };
866
909
  const staged = await stagedRecord(destination.directory, record);
867
910
  try {
868
911
  await rename(staged.stagedPath, destination.absolute);
@@ -872,12 +915,21 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
872
915
  }
873
916
  const published = await readPublished(destination);
874
917
  if (!published || canonical(published.record) !== canonical(record)) {
875
- throw recoveryError(receipt, 'submission acceptance could not be reread');
918
+ throw recoveryError(receipt, `${label} could not be reread`);
876
919
  }
877
920
  return { ...published, resumed: receipt.resumed };
878
921
  }
879
922
 
880
- 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 }) {
881
933
  let receipt = await beginExternalMutation(input, identity, receiptIo, publicationFacts);
882
934
  if (receipt.record?.state === 'observed-policy-violation') {
883
935
  throw new Error('External mutation receipt records an observed policy violation and is cleanup-only.');
@@ -886,27 +938,93 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
886
938
  throw new Error('External mutation receipt state is unsafe.');
887
939
  }
888
940
  if (!receipt.resumed) {
889
- try {
890
- const acceptance = await dispatch();
891
- if (acceptance?.venueOrderId) receipt = await recordSubmissionAcceptance(input, receipt, acceptance);
892
- } catch (error) {
893
- if (error instanceof ExternalMutationNotDispatchedError) {
894
- await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
895
- throw Object.assign(
896
- new Error(
897
- `External mutation was safely not dispatched (${error.message}). ` +
898
- 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
899
- ),
900
- { knownNotDispatched: true },
901
- );
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
+ }
902
1014
  }
903
- throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
904
1015
  }
905
1016
  }
906
1017
  let observation;
907
1018
  try {
908
1019
  observation = await observe({ resumed: receipt.resumed, receipt: receipt.record });
909
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
+ }
910
1028
  throw recoveryError(receipt, error instanceof Error ? error.message : String(error));
911
1029
  }
912
1030
  if (!observation || observation.success !== true) {
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { dataTestId, marketSymbol, normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
9
9
  import {
10
10
  assertTrustedExternalMutationTarget,
11
+ BACKGROUND_ACCEPTANCE_SOURCE,
11
12
  externalMutationNotDispatched,
12
13
  readExternalMutationReceipt,
13
14
  readPriorMutationReceipt,
@@ -27,8 +28,11 @@ import {
27
28
  assertFlatBeforeRoundTrip,
28
29
  assertHouseLimitPosition,
29
30
  closeImmediatelyRequested,
31
+ marketOrderLot,
30
32
  requireHouseNotional,
31
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';
32
36
  import {
33
37
  orderIdentityMatches,
34
38
  parseOrderCard,
@@ -343,23 +347,36 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
343
347
  };
344
348
  }
345
349
 
346
- async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
347
- const accepted = placement.record?.acceptance;
348
- 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) {
349
364
  const { provider, network } = await requirePerpsTradingContext(input, page);
350
- 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`);
351
366
  const wallet = await page.evaluate(readWalletStateExpression());
352
367
  const user = String(wallet.selectedAccount?.address ?? '').toLowerCase();
353
368
  if (!/^0x[a-f0-9]{40}$/u.test(user)
354
- || createHash('sha256').update(user).digest('hex') !== placement.record.identity.selectedIdentitySha256) {
355
- 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`);
356
371
  }
357
- const origin = network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
358
- const response = await fetch(`${origin}/info`, {
359
- method: 'POST', headers: { 'content-type': 'application/json' },
360
- body: JSON.stringify({ type: 'orderStatus', user, oid: Number(accepted.venueOrderId) }),
361
- signal: AbortSignal.timeout(10_000),
362
- });
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) });
363
380
  if (!response.ok) throw new Error('Accepted-order status request failed.\nNext: mm-harness last --json');
364
381
  const result = await response.json();
365
382
  if (result.status === 'unknownOid') throw new Error('Accepted venue order status is unavailable.\nNext: mm-harness last --json');
@@ -669,7 +686,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
669
686
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
670
687
  throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
671
688
  }
672
- const price = await page.evaluate(String.raw`(async () => {
689
+ const price = await withRateLimitRetry('perpsGetMarketDataWithPrices', () => page.evaluate(String.raw`(async () => {
673
690
  const request = globalThis.stateHooks?.submitRequestToBackground;
674
691
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
675
692
  const symbol = ${JSON.stringify(symbol)};
@@ -707,7 +724,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
707
724
  const line = body.split('\n').find((text) => /\$[0-9][0-9,]*(?:\.[0-9]+)?/.test(text));
708
725
  const fromBody = parsePrice(line);
709
726
  return fromBody ? { price: fromBody, source: 'document-body-price-line' } : null;
710
- })()`);
727
+ })()`), { deadline: page.actionDeadline });
711
728
  const numericPrice = Number(price?.price ?? price);
712
729
  if (!Number.isFinite(numericPrice) || numericPrice <= 0) throw new Error(`Unable to determine current Perps price for ${symbol}.`);
713
730
  return numericPrice;
@@ -780,8 +797,33 @@ function backgroundPositionObservation(position) {
780
797
  return { market, side, leverage, size: String(position?.size ?? position?.szi), marginBaseUnits };
781
798
  }
782
799
 
783
- async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
800
+ // Whole size steps in a size, or null when it is off the step grid.
801
+ function sizeLots(size, sizeDecimals) {
802
+ const scaled = Math.abs(Number(size)) * 10 ** sizeDecimals;
803
+ const lots = Math.round(scaled);
804
+ return Number.isSafeInteger(lots) && lots > 0 && Math.abs(scaled - lots) < 1e-6 ? lots : null;
805
+ }
806
+
807
+ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs, acceptance) {
784
808
  const deadline = Date.now() + timeoutMs;
809
+ const lot = expected.orderType === 'market' ? expected.marketLot : null;
810
+ // A venue-reported fill fixes the exact size; without one the size may be a step
811
+ // off the lot computed here, since the controller sizes at its own fresh price.
812
+ const acceptedLots = lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
813
+ ? sizeLots(acceptance.filledSize, lot.sizeDecimals) : null;
814
+ if (lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
815
+ && (acceptedLots == null || Math.abs(acceptedLots - lot.lots) > 1)) {
816
+ throw new Error('Background Perps market fill size is not within one size step of the requested amount.');
817
+ }
818
+ const marginToleranceBaseUnits = lot
819
+ ? `${BigInt(expected.roundingToleranceBaseUnits) + BigInt(lot.stepMarginBaseUnits)}`
820
+ : expected.roundingToleranceBaseUnits;
821
+ const reported = acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE;
822
+ const sizeMatches = (size) => {
823
+ if (!lot) return !reported || Math.abs(Number(size)) === Number(acceptance.filledSize);
824
+ const observed = sizeLots(size, lot.sizeDecimals);
825
+ return observed != null && (acceptedLots != null ? observed === acceptedLots : Math.abs(observed - lot.lots) <= 1);
826
+ };
785
827
  while (Date.now() <= deadline) {
786
828
  if (expected.orderType === 'limit') {
787
829
  const matches = (await readOpenOrders(page))
@@ -798,12 +840,20 @@ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
798
840
  && position.market === expected.market
799
841
  && position.side === expected.side
800
842
  && position.leverage === expected.leverage
843
+ && sizeMatches(position.size)
801
844
  && withinBaseUnitTolerance(
802
845
  position.marginBaseUnits,
803
846
  expected.amountBaseUnits,
804
- expected.roundingToleranceBaseUnits,
847
+ marginToleranceBaseUnits,
805
848
  ));
806
- if (matches.length === 1) return { success: true, position: matches[0] };
849
+ if (matches.length === 1) {
850
+ // The step tolerance widens the identity match, never the exposure the run allowed.
851
+ if (expected.maximumNotionalBaseUnits != null
852
+ && BigInt(matches[0].marginBaseUnits) * BigInt(matches[0].leverage) > BigInt(expected.maximumNotionalBaseUnits)) {
853
+ throw new Error('Background Perps position exposure exceeds the runner-trusted operation cap.');
854
+ }
855
+ return { success: true, position: matches[0] };
856
+ }
807
857
  if (matches.length > 1) throw new Error('Background position postcondition matched more than one position.');
808
858
  }
809
859
  await sleep(250);
@@ -870,12 +920,17 @@ async function dispatchBackgroundPlace(page, {
870
920
  };
871
921
  const result = await request('perpsPlaceOrder', [orderParams]);
872
922
  if (!result || result.success !== true) throw new Error(result?.error || 'perpsPlaceOrder failed.');
873
- return { success: true };
923
+ return {
924
+ success: true,
925
+ orderId: result.orderId == null ? null : String(result.orderId),
926
+ filledSize: result.filledSize == null ? null : String(result.filledSize),
927
+ averagePrice: result.averagePrice == null ? null : String(result.averagePrice),
928
+ };
874
929
  })()`, { awaitPromise: true });
875
930
  }
876
931
 
877
932
  async function readOpenOrdersState(page) {
878
- const state = await page.evaluate(`(async () => {
933
+ const state = await withRateLimitRetry('perpsGetOpenOrders', () => page.evaluate(`(async () => {
879
934
  const request = globalThis.stateHooks?.submitRequestToBackground;
880
935
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
881
936
  const cached = manager?.orders?.cache;
@@ -893,13 +948,13 @@ async function readOpenOrdersState(page) {
893
948
  connected: Boolean(manager?.orders?.isConnected),
894
949
  orders: Array.isArray(cached) ? cached : [],
895
950
  };
896
- })()`);
951
+ })()`), { deadline: page.actionDeadline });
897
952
  if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
898
953
  return state;
899
954
  }
900
955
 
901
956
  async function readAccountState(page) {
902
- const state = await page.evaluate(`(async () => {
957
+ const state = await withRateLimitRetry('perpsGetAccountState', () => page.evaluate(`(async () => {
903
958
  const request = globalThis.stateHooks?.submitRequestToBackground;
904
959
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
905
960
  const cached = manager?.account?.cache;
@@ -917,7 +972,7 @@ async function readAccountState(page) {
917
972
  connected: Boolean(manager?.account?.isConnected),
918
973
  account: cached && typeof cached === 'object' ? cached : null,
919
974
  };
920
- })()`);
975
+ })()`), { deadline: page.actionDeadline });
921
976
  if (!state.available) throw new Error('Perps account state is unavailable from both background and stream manager cache.');
922
977
  return state.account;
923
978
  }
@@ -1202,6 +1257,52 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
1202
1257
  throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
1203
1258
  }
1204
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
+
1205
1306
  async function placeOrderThroughBackground(input, page, {
1206
1307
  amount,
1207
1308
  bound = null,
@@ -1246,20 +1347,44 @@ async function placeOrderThroughBackground(input, page, {
1246
1347
  }
1247
1348
  }
1248
1349
  }
1350
+ if (!existing && expected.marketLot
1351
+ && BigInt(expected.marketLot.notionalBaseUnits) > BigInt(expected.maximumNotionalBaseUnits)) {
1352
+ throw new Error('Rounded Perps market lot exceeds the runner-trusted operation cap.');
1353
+ }
1354
+ // A resume checks the fill against the lot sized at publication, not one
1355
+ // recomputed at a price that has since moved.
1356
+ const marketLot = existing?.record?.facts?.marketLot ?? expected.marketLot ?? null;
1249
1357
  const mutation = await runGuardedExternalMutation({
1250
1358
  input,
1251
1359
  identity,
1252
- publicationFacts: { baselineOrderIds: [...baselineIds], fundingBalance },
1253
- dispatch: async () => {
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
+ },
1367
+ // The pre-order balance lets a receipt-bound close reconcile its final balance.
1368
+ publicationFacts: {
1369
+ balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
1370
+ baselineOrderIds: [...baselineIds],
1371
+ fundingBalance,
1372
+ ...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
1373
+ },
1374
+ dispatch: async ({ deadline = null } = {}) => {
1254
1375
  try {
1376
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
1255
1377
  await requireCurrentVisibleFundingBalance(
1256
1378
  input,
1257
1379
  page,
1258
- Number(input.node?.timeout_ms ?? 30_000),
1380
+ deadline == null ? timeoutMs : Math.max(0, Math.min(timeoutMs, deadline - Date.now())),
1259
1381
  fundingBalance.observedBaseUnits,
1260
1382
  );
1261
1383
  await requirePerpsTradingContext(input, page);
1262
1384
  assertTrustedExternalMutationTarget(input, page, identity);
1385
+ if (deadline != null && Date.now() >= deadline) {
1386
+ throw new Error('it reached the action deadline before dispatch');
1387
+ }
1263
1388
  } catch (error) {
1264
1389
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
1265
1390
  }
@@ -1278,13 +1403,26 @@ async function placeOrderThroughBackground(input, page, {
1278
1403
  if (accepted?.changed) {
1279
1404
  throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
1280
1405
  }
1406
+ // The venue's filled order ID goes into the receipt, so cleanup can prove the
1407
+ // position is this order's own.
1408
+ if (orderType === 'market' && accepted?.orderId != null && accepted.filledSize != null && accepted.averagePrice != null) {
1409
+ return {
1410
+ source: BACKGROUND_ACCEPTANCE_SOURCE,
1411
+ venueOrderId: accepted.orderId,
1412
+ status: 'filled',
1413
+ size: accepted.filledSize,
1414
+ filledSize: accepted.filledSize,
1415
+ averagePrice: accepted.averagePrice,
1416
+ };
1417
+ }
1281
1418
  return accepted;
1282
1419
  },
1283
- observe: async () => observeBackgroundPlace(
1420
+ observe: async ({ receipt }) => observeBackgroundPlace(
1284
1421
  page,
1285
- expected,
1422
+ { ...expected, marketLot },
1286
1423
  baselineIds,
1287
1424
  Number(input.node?.timeout_ms ?? 30_000),
1425
+ receipt?.acceptance,
1288
1426
  ),
1289
1427
  });
1290
1428
  return {
@@ -1501,12 +1639,8 @@ export async function observeHyperliquidSubmission(page, expected) {
1501
1639
  if (expected.provider !== 'hyperliquid' || !['market', 'limit'].includes(expected.orderType)) {
1502
1640
  throw new Error('Exact visible submission observation requires a Hyperliquid market or limit order.');
1503
1641
  }
1504
- const origin = expected.network === 'testnet'
1505
- ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
1506
- const metadataResponse = await fetch(`${origin}/info`, {
1507
- method: 'POST', headers: { 'content-type': 'application/json' },
1508
- body: JSON.stringify({ type: 'meta' }), signal: AbortSignal.timeout(10_000),
1509
- });
1642
+ const origin = hyperliquidOrigin(expected.network);
1643
+ const metadataResponse = await postHyperliquidInfo(expected.network, { type: 'meta' });
1510
1644
  if (!metadataResponse.ok) throw new Error('Hyperliquid market metadata is unavailable before submission.');
1511
1645
  const metadata = await metadataResponse.json();
1512
1646
  const asset = metadata.universe?.findIndex((market) => market.name === expected.market);
@@ -1676,8 +1810,9 @@ export async function placeOrder(input) {
1676
1810
  const slippage = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300) / 10_000;
1677
1811
  const margin = Number(amount);
1678
1812
  if (szDecimals == null) throw new Error(`close_immediately cannot read the ${symbol} size decimals; not dispatched.`);
1679
- const tolerance = Math.ceil(((10 ** -Number(szDecimals)) * Number(price) / leverage + margin * slippage) * 1_000_000) + 10_000;
1680
- if (!Number.isInteger(Number(szDecimals)) || !Number.isFinite(tolerance) || tolerance > 1_000_000) {
1813
+ const lot = marketOrderLot({ amount, leverage, price, szDecimals });
1814
+ const tolerance = lot ? Number(lot.stepMarginBaseUnits) + Math.ceil(margin * slippage * 1_000_000) + 10_000 : Number.NaN;
1815
+ if (!Number.isFinite(tolerance) || tolerance > 1_000_000) {
1681
1816
  throw new Error(`close_immediately cannot prove a ${symbol} fill within $1 (size step or slippage too coarse); pass identity_rounding_tolerance_base_units or choose another market.`);
1682
1817
  }
1683
1818
  // The receipt identity must not move with the price, so a resume finds it:
@@ -1743,15 +1878,18 @@ export async function placeOrder(input) {
1743
1878
  symbol,
1744
1879
  input.node?.current_price ?? input.node?.currentPrice,
1745
1880
  );
1881
+ const szDecimals = lane.network === 'mainnet' || orderType === 'market'
1882
+ ? await readSizeDecimals(page, symbol) : null;
1746
1883
  houseNotional = requireHouseNotional({
1747
1884
  network: lane.network,
1748
1885
  symbol,
1749
1886
  amount,
1750
1887
  leverage,
1751
1888
  price: resolvedPrice,
1752
- szDecimals: lane.network === 'mainnet' ? await readSizeDecimals(page, symbol) : null,
1889
+ szDecimals,
1753
1890
  maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
1754
1891
  });
1892
+ const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
1755
1893
  const backgroundLimitPrice = orderType === 'limit'
1756
1894
  ? resolveLimitPrice(input, isBuy, resolvedPrice)
1757
1895
  : null;
@@ -1764,6 +1902,7 @@ export async function placeOrder(input) {
1764
1902
  expected: {
1765
1903
  ...expectedVisibleIdentity,
1766
1904
  limitPriceBaseUnits: backgroundLimitPrice == null ? null : parseUsdBaseUnits(backgroundLimitPrice),
1905
+ ...(marketLot ? { marketLot } : {}),
1767
1906
  },
1768
1907
  identity,
1769
1908
  isBuy,
@@ -2107,7 +2246,7 @@ export async function placeOrder(input) {
2107
2246
  // read in one evaluation with the context guard before, between and after the
2108
2247
  // reads, so a switch to another account's book and back can't pass as flat.
2109
2248
  async function readBoundBook(page, bound) {
2110
- const book = await page.evaluate(`(async () => {
2249
+ const book = await withRateLimitRetry('perpsGetPositions/perpsGetOpenOrders', () => page.evaluate(`(async () => {
2111
2250
  const request = globalThis.stateHooks?.submitRequestToBackground;
2112
2251
  if (typeof request !== 'function') return { unavailable: true };
2113
2252
  const guard = async () => { ${boundContextGuard(bound)} return null; };
@@ -2117,7 +2256,7 @@ async function readBoundBook(page, bound) {
2117
2256
  const orders = await request('perpsGetOpenOrders', []);
2118
2257
  if (await guard()) return { changed: true };
2119
2258
  return { positions, orders };
2120
- })()`);
2259
+ })()`), { deadline: page.actionDeadline });
2121
2260
  if (book?.changed) {
2122
2261
  throw Object.assign(
2123
2262
  new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
@@ -2131,9 +2270,9 @@ async function readBoundBook(page, bound) {
2131
2270
  }
2132
2271
 
2133
2272
  async function readSizeDecimals(page, symbol) {
2134
- const markets = await page.evaluate(
2273
+ const markets = await withRateLimitRetry('perpsGetMarkets', () => page.evaluate(
2135
2274
  `globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
2136
- );
2275
+ ), { deadline: page.actionDeadline });
2137
2276
  const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
2138
2277
  return market?.szDecimals ?? null;
2139
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);