@deeeed/metamask-harness 0.86.1 → 0.86.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.86.2 - 2026-10-09
6
+
7
+ ### Fixed
8
+
9
+ - Extension `metamask.perps.place_order` through the background Perps request accepts the venue's lot-rounded market fill. The controller sizes a USD order to the market's size step, so $12 of SOL at 109.94 fills 0.11 SOL with $12.09 margin. The observer required the margin within $0.01 of $12 and left every such placement `outcome-unknown`. It now reads the market's size decimals before dispatch and computes the lot the controller fills. The position size must equal the venue-reported filled size (or be within one step of the computed lot when the controller reports none, and never off the step grid), and the margin must be within one size step at the current price plus `identity_rounding_tolerance_base_units`. A reported fill more than one step from the computed lot fails. The lot is stored in the receipt (`facts.marketLot`), so a resume checks against it, not against a lot recomputed at a moved price. A lot whose notional exceeds the trusted operation cap is refused before the receipt. A filled position whose exposure (margin × leverage) exceeds the cap leaves the outcome unknown. Market, side and leverage still match exactly, and mainnet limits are unchanged. With no known size step the exact-cent check stays, and the size must equal a reported fill.
10
+ - Background market placements now record the venue's filled order (`acceptance.source: "background-perpsPlaceOrder"`, with `venueOrderId`, `filledSize` and `averagePrice` from `perpsPlaceOrder`) and the pre-order balance (`facts.balanceBeforeBaseUnits`). Before, the receipt had neither, so `metamask.perps.close_visible_position` refused cleanup with "Position cleanup requires the exact accepted venue order ID", even after a successful placement. Cleanup still proves the position from that order's own fills.
11
+
12
+ ### Changed
13
+
14
+ - Extension `metamask.perps.close_visible_position cleanup_only=true` now takes the released amount from the position's margin (`marginUsed`, already read from the position detail), not from the close modal text. It records the amount in the receipt as `closeReleaseBaseUnits` with `closeReleaseSource: "position-margin"`. Unrealized PnL is not added, because the available balance (Hyperliquid `withdrawable`) already includes it. The modal's "You'll receive" amount (`marginUsed` minus fees) is now a cross-check, recorded as `closeModalReceiveBaseUnits`. If it differs from the margin by more than the reconciliation tolerance, the action refuses before publishing a receipt or clicking. If the modal amount cannot be read, the action no longer refuses; it reconciles on the margin. The $1 tolerance is unchanged.
15
+
16
+ ### Fixed
17
+
18
+ - Mobile route waits (`navigate` and `waitForRoute` in the action library) now end at the calling action's deadline. Before, once a Perps `start_state` or `teardown_state` ran out of budget, the bridge refused each new `get-route` with `CDP_TIMEOUT`. The waits took that refusal for a target transition and kept polling for up to 30 s more on their own 15 s windows. They could not see a route that did arrive, and failed with "did not start before its action deadline: get-route". On a physical Pixel 6a this showed up as `CDP_TIMEOUT` in clean-market setup and teardown, while one `get-route` took 0.1 to 0.5 s. Both now stop at the deadline. A route that never matched fails with the expected route, the last route read and the limit applied; a recovery cut short by the deadline fails with its last bridge error.
19
+
5
20
  ## 0.86.1 - 2026-10-09
6
21
 
7
22
  ### Fixed
@@ -421,10 +421,13 @@ export async function closeVisiblePosition(input) {
421
421
  if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
422
422
  throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
423
423
  }
424
- // Without a placement receipt the baseline is the in-position balance, which excludes the released margin.
425
- const closeReleaseBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
426
- if (!placement && closeReleaseBaseUnits == null) {
427
- throw new Error('Close without a placement receipt requires the visible close receive amount.');
424
+ // Without a placement receipt the baseline is the in-position balance, which excludes the position's
425
+ // margin. The close returns marginUsed (PnL is already in the balance); the modal receive cross-checks it.
426
+ const closeReleaseBaseUnits = placement ? null : exactBefore.marginBaseUnits;
427
+ const closeModalReceiveBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
428
+ if (closeModalReceiveBaseUnits != null
429
+ && BigInt(absoluteDelta(closeModalReceiveBaseUnits, closeReleaseBaseUnits)) > BigInt(balanceToleranceBaseUnits)) {
430
+ throw new Error('Visible close receive amount disagrees with the position margin beyond the reconciliation tolerance.');
428
431
  }
429
432
  const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
430
433
  let submissionObserver = null;
@@ -440,7 +443,8 @@ export async function closeVisiblePosition(input) {
440
443
  cleanupOnly,
441
444
  fundingBalance,
442
445
  tradingContext,
443
- ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
446
+ ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits, closeReleaseSource: 'position-margin' } : {}),
447
+ ...(closeModalReceiveBaseUnits != null ? { closeModalReceiveBaseUnits } : {}),
444
448
  ...(acceptedFill ? { acceptedFill } : {}),
445
449
  ...(closeSizing ? { closeSizing } : {}),
446
450
  },
@@ -831,8 +831,27 @@ export async function recordExternalMutationCleanupCandidate(input, receipt, can
831
831
  });
832
832
  }
833
833
 
834
+ // A market order placed through the background Perps request reports the venue's
835
+ // filled order itself; there is no observed network request to bind.
836
+ export const BACKGROUND_ACCEPTANCE_SOURCE = 'background-perpsPlaceOrder';
837
+
838
+ function validFilledOrder(filledSize, averagePrice) {
839
+ return /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,8})?$/u.test(String(filledSize)) && Number(filledSize) > 0
840
+ && /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$/u.test(String(averagePrice)) && Number(averagePrice) > 0;
841
+ }
842
+
834
843
  async function recordSubmissionAcceptance(input, receipt, acceptance) {
835
844
  const { venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits, filledSize, averagePrice } = acceptance;
845
+ if (acceptance.source === BACKGROUND_ACCEPTANCE_SOURCE) {
846
+ if (receipt.record.identity.operation !== 'perps-market-order' || receipt.record.identity.provider !== 'hyperliquid'
847
+ || !/^[1-9][0-9]{0,19}$/u.test(String(venueOrderId)) || status !== 'filled' || size !== filledSize
848
+ || !validFilledOrder(filledSize, averagePrice)) {
849
+ throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
850
+ }
851
+ return writeSubmissionAcceptance(input, receipt, {
852
+ source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
853
+ });
854
+ }
836
855
  const closing = receipt.record.identity.operation === 'perps-close-position';
837
856
  const market = closing || receipt.record.identity.operation === 'perps-market-order';
838
857
  const sizing = closing ? receipt.record.facts?.closeSizing
@@ -849,20 +868,22 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
849
868
  || !BASE_UNITS.test(String(limitPriceBaseUnits))) {
850
869
  throw recoveryError(receipt, 'submission acceptance has invalid order identity');
851
870
  }
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))) {
871
+ if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
855
872
  throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
856
873
  }
874
+ return writeSubmissionAcceptance(input, receipt, {
875
+ venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
876
+ ...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
877
+ });
878
+ }
879
+
880
+ async function writeSubmissionAcceptance(input, receipt, acceptance) {
857
881
  const destination = await receiptDestination(input);
858
882
  const current = await readPublished(destination);
859
883
  if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
860
884
  throw recoveryError(receipt, 'the receipt changed before submission acceptance was recorded');
861
885
  }
862
- const record = { ...current.record, acceptance: {
863
- venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
864
- ...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
865
- } };
886
+ const record = { ...current.record, acceptance };
866
887
  const staged = await stagedRecord(destination.directory, record);
867
888
  try {
868
889
  await rename(staged.stagedPath, destination.absolute);
@@ -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,6 +28,7 @@ import {
27
28
  assertFlatBeforeRoundTrip,
28
29
  assertHouseLimitPosition,
29
30
  closeImmediatelyRequested,
31
+ marketOrderLot,
30
32
  requireHouseNotional,
31
33
  } from '../../shared/perps/order-policy.mjs';
32
34
  import {
@@ -780,8 +782,33 @@ function backgroundPositionObservation(position) {
780
782
  return { market, side, leverage, size: String(position?.size ?? position?.szi), marginBaseUnits };
781
783
  }
782
784
 
783
- async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
785
+ // Whole size steps in a size, or null when it is off the step grid.
786
+ function sizeLots(size, sizeDecimals) {
787
+ const scaled = Math.abs(Number(size)) * 10 ** sizeDecimals;
788
+ const lots = Math.round(scaled);
789
+ return Number.isSafeInteger(lots) && lots > 0 && Math.abs(scaled - lots) < 1e-6 ? lots : null;
790
+ }
791
+
792
+ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs, acceptance) {
784
793
  const deadline = Date.now() + timeoutMs;
794
+ const lot = expected.orderType === 'market' ? expected.marketLot : null;
795
+ // A venue-reported fill fixes the exact size; without one the size may be a step
796
+ // off the lot computed here, since the controller sizes at its own fresh price.
797
+ const acceptedLots = lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
798
+ ? sizeLots(acceptance.filledSize, lot.sizeDecimals) : null;
799
+ if (lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
800
+ && (acceptedLots == null || Math.abs(acceptedLots - lot.lots) > 1)) {
801
+ throw new Error('Background Perps market fill size is not within one size step of the requested amount.');
802
+ }
803
+ const marginToleranceBaseUnits = lot
804
+ ? `${BigInt(expected.roundingToleranceBaseUnits) + BigInt(lot.stepMarginBaseUnits)}`
805
+ : expected.roundingToleranceBaseUnits;
806
+ const reported = acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE;
807
+ const sizeMatches = (size) => {
808
+ if (!lot) return !reported || Math.abs(Number(size)) === Number(acceptance.filledSize);
809
+ const observed = sizeLots(size, lot.sizeDecimals);
810
+ return observed != null && (acceptedLots != null ? observed === acceptedLots : Math.abs(observed - lot.lots) <= 1);
811
+ };
785
812
  while (Date.now() <= deadline) {
786
813
  if (expected.orderType === 'limit') {
787
814
  const matches = (await readOpenOrders(page))
@@ -798,12 +825,20 @@ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
798
825
  && position.market === expected.market
799
826
  && position.side === expected.side
800
827
  && position.leverage === expected.leverage
828
+ && sizeMatches(position.size)
801
829
  && withinBaseUnitTolerance(
802
830
  position.marginBaseUnits,
803
831
  expected.amountBaseUnits,
804
- expected.roundingToleranceBaseUnits,
832
+ marginToleranceBaseUnits,
805
833
  ));
806
- if (matches.length === 1) return { success: true, position: matches[0] };
834
+ if (matches.length === 1) {
835
+ // The step tolerance widens the identity match, never the exposure the run allowed.
836
+ if (expected.maximumNotionalBaseUnits != null
837
+ && BigInt(matches[0].marginBaseUnits) * BigInt(matches[0].leverage) > BigInt(expected.maximumNotionalBaseUnits)) {
838
+ throw new Error('Background Perps position exposure exceeds the runner-trusted operation cap.');
839
+ }
840
+ return { success: true, position: matches[0] };
841
+ }
807
842
  if (matches.length > 1) throw new Error('Background position postcondition matched more than one position.');
808
843
  }
809
844
  await sleep(250);
@@ -870,7 +905,12 @@ async function dispatchBackgroundPlace(page, {
870
905
  };
871
906
  const result = await request('perpsPlaceOrder', [orderParams]);
872
907
  if (!result || result.success !== true) throw new Error(result?.error || 'perpsPlaceOrder failed.');
873
- return { success: true };
908
+ return {
909
+ success: true,
910
+ orderId: result.orderId == null ? null : String(result.orderId),
911
+ filledSize: result.filledSize == null ? null : String(result.filledSize),
912
+ averagePrice: result.averagePrice == null ? null : String(result.averagePrice),
913
+ };
874
914
  })()`, { awaitPromise: true });
875
915
  }
876
916
 
@@ -1246,10 +1286,23 @@ async function placeOrderThroughBackground(input, page, {
1246
1286
  }
1247
1287
  }
1248
1288
  }
1289
+ if (!existing && expected.marketLot
1290
+ && BigInt(expected.marketLot.notionalBaseUnits) > BigInt(expected.maximumNotionalBaseUnits)) {
1291
+ throw new Error('Rounded Perps market lot exceeds the runner-trusted operation cap.');
1292
+ }
1293
+ // A resume checks the fill against the lot sized at publication, not one
1294
+ // recomputed at a price that has since moved.
1295
+ const marketLot = existing?.record?.facts?.marketLot ?? expected.marketLot ?? null;
1249
1296
  const mutation = await runGuardedExternalMutation({
1250
1297
  input,
1251
1298
  identity,
1252
- publicationFacts: { baselineOrderIds: [...baselineIds], fundingBalance },
1299
+ // The pre-order balance lets a receipt-bound close reconcile its final balance.
1300
+ publicationFacts: {
1301
+ balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
1302
+ baselineOrderIds: [...baselineIds],
1303
+ fundingBalance,
1304
+ ...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
1305
+ },
1253
1306
  dispatch: async () => {
1254
1307
  try {
1255
1308
  await requireCurrentVisibleFundingBalance(
@@ -1278,13 +1331,26 @@ async function placeOrderThroughBackground(input, page, {
1278
1331
  if (accepted?.changed) {
1279
1332
  throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
1280
1333
  }
1334
+ // The venue's filled order ID goes into the receipt, so cleanup can prove the
1335
+ // position is this order's own.
1336
+ if (orderType === 'market' && accepted?.orderId != null && accepted.filledSize != null && accepted.averagePrice != null) {
1337
+ return {
1338
+ source: BACKGROUND_ACCEPTANCE_SOURCE,
1339
+ venueOrderId: accepted.orderId,
1340
+ status: 'filled',
1341
+ size: accepted.filledSize,
1342
+ filledSize: accepted.filledSize,
1343
+ averagePrice: accepted.averagePrice,
1344
+ };
1345
+ }
1281
1346
  return accepted;
1282
1347
  },
1283
- observe: async () => observeBackgroundPlace(
1348
+ observe: async ({ receipt }) => observeBackgroundPlace(
1284
1349
  page,
1285
- expected,
1350
+ { ...expected, marketLot },
1286
1351
  baselineIds,
1287
1352
  Number(input.node?.timeout_ms ?? 30_000),
1353
+ receipt?.acceptance,
1288
1354
  ),
1289
1355
  });
1290
1356
  return {
@@ -1676,8 +1742,9 @@ export async function placeOrder(input) {
1676
1742
  const slippage = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300) / 10_000;
1677
1743
  const margin = Number(amount);
1678
1744
  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) {
1745
+ const lot = marketOrderLot({ amount, leverage, price, szDecimals });
1746
+ const tolerance = lot ? Number(lot.stepMarginBaseUnits) + Math.ceil(margin * slippage * 1_000_000) + 10_000 : Number.NaN;
1747
+ if (!Number.isFinite(tolerance) || tolerance > 1_000_000) {
1681
1748
  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
1749
  }
1683
1750
  // The receipt identity must not move with the price, so a resume finds it:
@@ -1743,15 +1810,18 @@ export async function placeOrder(input) {
1743
1810
  symbol,
1744
1811
  input.node?.current_price ?? input.node?.currentPrice,
1745
1812
  );
1813
+ const szDecimals = lane.network === 'mainnet' || orderType === 'market'
1814
+ ? await readSizeDecimals(page, symbol) : null;
1746
1815
  houseNotional = requireHouseNotional({
1747
1816
  network: lane.network,
1748
1817
  symbol,
1749
1818
  amount,
1750
1819
  leverage,
1751
1820
  price: resolvedPrice,
1752
- szDecimals: lane.network === 'mainnet' ? await readSizeDecimals(page, symbol) : null,
1821
+ szDecimals,
1753
1822
  maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
1754
1823
  });
1824
+ const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
1755
1825
  const backgroundLimitPrice = orderType === 'limit'
1756
1826
  ? resolveLimitPrice(input, isBuy, resolvedPrice)
1757
1827
  : null;
@@ -1764,6 +1834,7 @@ export async function placeOrder(input) {
1764
1834
  expected: {
1765
1835
  ...expectedVisibleIdentity,
1766
1836
  limitPriceBaseUnits: backgroundLimitPrice == null ? null : parseUsdBaseUnits(backgroundLimitPrice),
1837
+ ...(marketLot ? { marketLot } : {}),
1767
1838
  },
1768
1839
  identity,
1769
1840
  isBuy,
@@ -4,7 +4,10 @@ import { pathToFileURL } from 'node:url';
4
4
  import {
5
5
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
6
6
  bridgeCommand,
7
+ deadlineFromTimeout,
8
+ earliestDeadline,
7
9
  evalAsync,
10
+ inheritedDeadline,
8
11
  isTargetTransition,
9
12
  navigate,
10
13
  routeSatisfiesNavigationTarget,
@@ -94,23 +97,6 @@ const MODE_ROOT_TEST_IDS = {
94
97
  };
95
98
  const READ_ATTEMPT_TIMEOUT_MS = 10_000;
96
99
 
97
- function inheritedDeadline(input) {
98
- const deadline = Number(input.node?._action_deadline_epoch_ms);
99
- return Number.isFinite(deadline) ? deadline : null;
100
- }
101
-
102
- function earliestDeadline(input, deadline) {
103
- const inherited = inheritedDeadline(input);
104
- return inherited === null ? deadline : Math.min(deadline, inherited);
105
- }
106
-
107
- function deadlineFromTimeout(input, timeoutMs) {
108
- return earliestDeadline(
109
- input,
110
- Date.now() + Math.max(0, Number(timeoutMs)),
111
- );
112
- }
113
-
114
100
  async function sleepBeforeDeadline(deadline, maximumMs, wait = sleep) {
115
101
  const remainingMs = deadline - Date.now();
116
102
  if (remainingMs <= 0) return;
@@ -663,6 +663,24 @@ function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expec
663
663
  );
664
664
  }
665
665
 
666
+ export function inheritedDeadline(input) {
667
+ const deadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
668
+ return Number.isFinite(deadline) ? deadline : null;
669
+ }
670
+
671
+ export function earliestDeadline(input, deadline) {
672
+ const inherited = inheritedDeadline(input);
673
+ return inherited === null ? deadline : Math.min(deadline, inherited);
674
+ }
675
+
676
+ // The earlier of now + timeoutMs and the calling action's deadline. Route waits
677
+ // use it because past the action deadline bridgeCommand refuses to start
678
+ // get-route (CDP_TIMEOUT): polling on would only retry that refusal and could
679
+ // never see a route that did arrive.
680
+ export function deadlineFromTimeout(input, timeoutMs) {
681
+ return earliestDeadline(input, Date.now() + Math.max(0, Number(timeoutMs)));
682
+ }
683
+
666
684
  async function waitForNextRouteProbe(deadline) {
667
685
  const remaining = deadline - Date.now();
668
686
  if (remaining > 0) await sleep(Math.min(250, remaining));
@@ -712,7 +730,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
712
730
 
713
731
  while (navigateAttempts < 2 || recoveryPending) {
714
732
  if (recoveryPending) {
715
- const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
733
+ const deadline = recoveryDeadline ?? deadlineFromTimeout(input, timeoutMs);
716
734
  let currentRoute = null;
717
735
  while (Date.now() < deadline) {
718
736
  try {
@@ -798,7 +816,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
798
816
  if (!isTargetTransition(error)) throw error;
799
817
  lastTransitionError = error;
800
818
  transitionCodes.push(error.code);
801
- recoveryDeadline = Date.now() + timeoutMs;
819
+ recoveryDeadline = deadlineFromTimeout(input, timeoutMs);
802
820
  recoveryPending = true;
803
821
  await waitForNextRouteProbe(recoveryDeadline);
804
822
  }
@@ -820,7 +838,8 @@ export async function waitForRoute(
820
838
  expectedParams,
821
839
  ) {
822
840
  const expected = String(expectedRoute);
823
- const deadline = Date.now() + timeoutMs;
841
+ const startedAt = Date.now();
842
+ const deadline = deadlineFromTimeout(input, timeoutMs);
824
843
  // lastRoute is null when bridgeCommand returns null (transient: route not yet
825
844
  // settled mid-navigation). Null means "not ready yet" — keep polling.
826
845
  let lastRoute = null;
@@ -857,7 +876,7 @@ export async function waitForRoute(
857
876
  ? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
858
877
  : JSON.stringify(lastRoute);
859
878
  const timeoutError = new Error(
860
- `Timed out waiting for Mobile route '${expected}' after ${timeoutMs}ms (${pollCount} polls).\n` +
879
+ `Timed out waiting for Mobile route '${expected}' after ${Date.now() - startedAt}ms (${pollCount} polls; limit ${deadline - startedAt}ms of ${timeoutMs}ms requested).\n` +
861
880
  ` Expected route: ${expected}\n` +
862
881
  ` Expected params: ${expectedParams === undefined ? 'any' : JSON.stringify(expectedParams)}\n` +
863
882
  ` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
@@ -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 }) {
@@ -4465,7 +4465,7 @@
4465
4465
  "identity_rounding_tolerance_base_units": {
4466
4466
  "type": "string",
4467
4467
  "default": "10000",
4468
- "description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000."
4468
+ "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
4469
  }
4470
4470
  },
4471
4471
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.86.1",
3
+ "version": "0.86.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"