@deeeed/metamask-harness 0.86.0 → 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,29 @@
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
+
20
+ ## 0.86.1 - 2026-10-09
21
+
22
+ ### Fixed
23
+
24
+ - Extension `metamask.perps.close_visible_position cleanup_only=true` reconciles the final balance against the margin the close releases. With no placement receipt, the baseline is the available balance read while the position is still open. That baseline excludes the position's margin, so a clean full close went over the $1 tolerance and left an `outcome-unknown` receipt. A 0.11 SOL testnet close moved $524.42 to $536.45 and failed this way. The action now reads the close modal's "You'll receive" amount before dispatch and records it in the receipt as `closeReleaseBaseUnits`. It then checks the final balance against baseline + receive, with the same tolerance. If the amount is not visible, the action refuses before publishing a receipt or clicking. Closes backed by a placement receipt still reconcile against the pre-order balance. Cleanup output adds `releaseBaseUnits` and `expectedBalanceAfterBaseUnits`.
25
+ - Mobile `metamask.perps.read_visible_state` on iOS sees Perps Text test IDs. iOS accessibility lists React Native Text as a StaticText with no identifier, so a visible Lite market price (`perps-market-header-price`) failed `require_features: ["price"]`. On a non-opaque iOS run, `ui.visible` now reads each mounted `perps-` Text's test ID, string and size over the dev bridge. It gives that test ID to the on-screen StaticText with the same label and size, when exactly one test ID matches. Offscreen StaticText stays unbound, and so does a same-text twin of another size (the collapsed header price). Other Text, such as a revealed Secret Recovery Phrase word, is not read. This reaches every iOS `ui.visible` consumer: `ui.locators`, native-navigation test-ID waits, `readVisiblePerpsTestIds`, `configure_order`, and the assets and swap-bridge `read_visible_state`. Opaque runtimes and Android are unchanged. A non-opaque run with Metro down pays one bridge attempt (at most 2 s) per observation and gets a `ui.visible` warning; a read over 500 Texts is truncated with a warning.
26
+ - An empty `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` means the bundled `cdp-bridge.cjs` everywhere the TypeScript side spawns the Mobile bridge, as `run` and the action library already treated it. `launch`'s feature-flag reads and pins (and `doctor`'s) and the `status` live probe read the variable with `??`. An empty value made them check for a script named `""`, so they reported no runtime and never spawned a bridge. All TypeScript callers now share one `mobileBridgeScript()`; the action library keeps the same rule in its own `bridgeScript()` because it cannot import `src/`.
27
+
5
28
  ## 0.86.0 - 2026-10-09
6
29
 
7
30
  ### Added
@@ -0,0 +1,12 @@
1
+ import path from "node:path";
2
+ import { runnerDir } from "../../paths.js";
3
+ function mobileBridgeScriptOverride() {
4
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || void 0;
5
+ }
6
+ function mobileBridgeScript() {
7
+ return mobileBridgeScriptOverride() ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
+ }
9
+ export {
10
+ mobileBridgeScript,
11
+ mobileBridgeScriptOverride
12
+ };
@@ -1,11 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
- import { runnerDir } from "../../paths.js";
3
+ import { mobileBridgeScript } from "./bridge-script.js";
5
4
  const DEFAULT_TIMEOUT_MS = 1e4;
6
- function bridgeScriptPath() {
7
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
8
- }
9
5
  const CONTROLLER_PREAMBLE = `
10
6
  var ctx = globalThis.Engine && globalThis.Engine.context;
11
7
  var c = ctx && ctx.RemoteFeatureFlagController;
@@ -19,7 +15,7 @@ function evalExpression(body) {
19
15
  return `(function(){ try {${CONTROLLER_PREAMBLE}${body} } catch (e) { return JSON.stringify({ ok: false, error: e && (e.message || String(e)) }); } })()`;
20
16
  }
21
17
  function runBridgeEval(target, expression, timeoutMs) {
22
- const script = bridgeScriptPath();
18
+ const script = mobileBridgeScript();
23
19
  if (!fs.existsSync(script)) return Promise.resolve(null);
24
20
  return new Promise((resolve) => {
25
21
  let settled = false;
@@ -2,7 +2,6 @@ import { EXIT, isRecord } from "@farmslot/recipe-cli/harness";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { IOS_SIMULATOR_RECORDING_TARGET_PREFIX } from "@farmslot/adapter-rn";
5
- import { runnerDir } from "../../paths.js";
6
5
  import { devicePlatformFromEnv } from "../performance/device-platform.js";
7
6
  import {
8
7
  resolveAndroidDeviceSerial,
@@ -10,6 +9,7 @@ import {
10
9
  } from "./recording-target.js";
11
10
  import { emitProofFailure } from "../proof-failure.js";
12
11
  import { mobileAppIdentityEnv } from "./app-identity.js";
12
+ import { mobileBridgeScript } from "./bridge-script.js";
13
13
  import { requireCurrentMobileReleaseArtifactState } from "./release-artifact-state.js";
14
14
  const EXPLICIT_DEVICE_KEYS = [
15
15
  "PLATFORM",
@@ -158,7 +158,7 @@ const mobileObservation = {
158
158
  }
159
159
  };
160
160
  function runMobileIssueCommand(projectRoot, command) {
161
- const bridge = process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
161
+ const bridge = mobileBridgeScript();
162
162
  const result = spawnSync(process.execPath, [bridge, command], {
163
163
  cwd: projectRoot,
164
164
  env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
@@ -10,6 +10,7 @@ import { trustedMutationContextExtras } from "../../funding-execution-context.js
10
10
  import { LIBRARY_ACTION_NAMESPACE } from "../../paths.js";
11
11
  import { liveTsxCandidates } from "../../live-tsx.js";
12
12
  import { mobileAppIdentityEnv } from "./app-identity.js";
13
+ import { mobileBridgeScriptOverride } from "./bridge-script.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  function sleep(ms) {
15
16
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -58,7 +59,7 @@ function mobileProbeOutput(status, input, projectRoot) {
58
59
  };
59
60
  }
60
61
  function mobileBridgePath(_projectRoot) {
61
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
62
+ return mobileBridgeScriptOverride() ?? "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
62
63
  }
63
64
  function resolveMobileWaitTarget(payload) {
64
65
  const testId = optionalScalarText(
@@ -1,13 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
3
  import { fixtureSummary } from "../doctor.js";
5
4
  import { normalizeFeatureFlagRecord } from "../feature-flags.js";
6
- import { runnerDir } from "../paths.js";
5
+ import { mobileBridgeScript } from "../adapters/mobile/bridge-script.js";
7
6
  const PROBE_TIMEOUT_MS = 2e3;
8
- function bridgeScriptPath() {
9
- return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
10
- }
11
7
  function truncateAddress(address) {
12
8
  if (!address || address.length <= 10) return address;
13
9
  return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
@@ -77,7 +73,7 @@ function matchBridgeEntry(device, entries, devices) {
77
73
  return null;
78
74
  }
79
75
  function probeMobileBridge(target) {
80
- const script = bridgeScriptPath();
76
+ const script = mobileBridgeScript();
81
77
  if (!fs.existsSync(script)) {
82
78
  return Promise.resolve({ ok: false, liveState: "no-bridge" });
83
79
  }
@@ -208,10 +208,11 @@ async function prepareCloseModal(page, partialAmount) {
208
208
  return confirmation;
209
209
  }
210
210
 
211
- async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
211
+ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, releaseBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
212
212
  await page.navigateHash('#/perps-home');
213
213
  const state = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: true });
214
- const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, balanceBeforeBaseUnits);
214
+ const expectedBalanceAfterBaseUnits = `${BigInt(balanceBeforeBaseUnits) + BigInt(releaseBaseUnits ?? '0')}`;
215
+ const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, expectedBalanceAfterBaseUnits);
215
216
  if (BigInt(balanceDeltaBaseUnits) > BigInt(toleranceBaseUnits)) {
216
217
  throw new Error('Final visible Perps balance exceeds the authorized reconciliation tolerance.');
217
218
  }
@@ -219,6 +220,8 @@ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUni
219
220
  positionCount: 0,
220
221
  orderCount: 0,
221
222
  balanceBeforeBaseUnits,
223
+ releaseBaseUnits: releaseBaseUnits ?? null,
224
+ expectedBalanceAfterBaseUnits,
222
225
  balanceAfterBaseUnits: state.balanceBaseUnits,
223
226
  balanceDeltaBaseUnits,
224
227
  toleranceBaseUnits,
@@ -327,9 +330,14 @@ export async function closeVisiblePosition(input) {
327
330
  if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceBeforeBaseUnits)) {
328
331
  throw new Error('Resumed close receipt has no valid pre-order balance.');
329
332
  }
333
+ const releaseBaseUnits = existingReceipt.record?.facts?.closeReleaseBaseUnits ?? null;
334
+ if (releaseBaseUnits != null && !/^-?(?:0|[1-9][0-9]{0,29})$/u.test(String(releaseBaseUnits))) {
335
+ throw new Error('Resumed close receipt has an invalid close release amount.');
336
+ }
330
337
  const cleanup = await proveGlobalCleanup(
331
338
  page,
332
339
  balanceBeforeBaseUnits,
340
+ releaseBaseUnits,
333
341
  balanceToleranceBaseUnits,
334
342
  );
335
343
  return {
@@ -413,6 +421,14 @@ export async function closeVisiblePosition(input) {
413
421
  if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
414
422
  throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
415
423
  }
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.');
431
+ }
416
432
  const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
417
433
  let submissionObserver = null;
418
434
  let mutation;
@@ -427,6 +443,8 @@ export async function closeVisiblePosition(input) {
427
443
  cleanupOnly,
428
444
  fundingBalance,
429
445
  tradingContext,
446
+ ...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits, closeReleaseSource: 'position-margin' } : {}),
447
+ ...(closeModalReceiveBaseUnits != null ? { closeModalReceiveBaseUnits } : {}),
430
448
  ...(acceptedFill ? { acceptedFill } : {}),
431
449
  ...(closeSizing ? { closeSizing } : {}),
432
450
  },
@@ -507,6 +525,7 @@ export async function closeVisiblePosition(input) {
507
525
  const cleanup = await proveGlobalCleanup(
508
526
  page,
509
527
  balanceBeforeBaseUnits,
528
+ closeReleaseBaseUnits,
510
529
  balanceToleranceBaseUnits,
511
530
  );
512
531
  return {
@@ -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;
@@ -107,6 +107,8 @@ export async function runAdapter(callback) {
107
107
  }
108
108
  }
109
109
 
110
+ // Same rule as src/adapters/mobile/bridge-script.ts (unset or empty means the
111
+ // bundled bridge); the library cannot import src/.
110
112
  function bridgeScript(input) {
111
113
  if (process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT) {
112
114
  return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT;
@@ -661,6 +663,24 @@ function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expec
661
663
  );
662
664
  }
663
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
+
664
684
  async function waitForNextRouteProbe(deadline) {
665
685
  const remaining = deadline - Date.now();
666
686
  if (remaining > 0) await sleep(Math.min(250, remaining));
@@ -710,7 +730,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
710
730
 
711
731
  while (navigateAttempts < 2 || recoveryPending) {
712
732
  if (recoveryPending) {
713
- const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
733
+ const deadline = recoveryDeadline ?? deadlineFromTimeout(input, timeoutMs);
714
734
  let currentRoute = null;
715
735
  while (Date.now() < deadline) {
716
736
  try {
@@ -796,7 +816,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
796
816
  if (!isTargetTransition(error)) throw error;
797
817
  lastTransitionError = error;
798
818
  transitionCodes.push(error.code);
799
- recoveryDeadline = Date.now() + timeoutMs;
819
+ recoveryDeadline = deadlineFromTimeout(input, timeoutMs);
800
820
  recoveryPending = true;
801
821
  await waitForNextRouteProbe(recoveryDeadline);
802
822
  }
@@ -818,7 +838,8 @@ export async function waitForRoute(
818
838
  expectedParams,
819
839
  ) {
820
840
  const expected = String(expectedRoute);
821
- const deadline = Date.now() + timeoutMs;
841
+ const startedAt = Date.now();
842
+ const deadline = deadlineFromTimeout(input, timeoutMs);
822
843
  // lastRoute is null when bridgeCommand returns null (transient: route not yet
823
844
  // settled mid-navigation). Null means "not ready yet" — keep polling.
824
845
  let lastRoute = null;
@@ -855,7 +876,7 @@ export async function waitForRoute(
855
876
  ? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
856
877
  : JSON.stringify(lastRoute);
857
878
  const timeoutError = new Error(
858
- `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` +
859
880
  ` Expected route: ${expected}\n` +
860
881
  ` Expected params: ${expectedParams === undefined ? 'any' : JSON.stringify(expectedParams)}\n` +
861
882
  ` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
@@ -16,6 +16,41 @@ import { mobileToolRecovery, resolveMobileToolPath } from '@farmslot/adapter-rn'
16
16
  const execFileAsync = promisify(execFile);
17
17
  const VISIBLE_LIMIT = 200;
18
18
  const HIDDEN_LIMIT = 100;
19
+ // iOS accessibility exposes React Native Text as StaticText without its testID.
20
+ // Read each mounted Perps Text's testID, string and size (synchronous on Fabric)
21
+ // so the visible native element with the same label and size can carry that
22
+ // testID. Only perps- Texts leave the app: other Text, such as a revealed
23
+ // Secret Recovery Phrase word, never reaches the harness or a trace.
24
+ const TEXT_TEST_ID_LIMIT = 500;
25
+ const IOS_TEXT_TEST_IDS = `(() => {
26
+ const ui = globalThis.nativeFabricUIManager;
27
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
28
+ if (!ui?.measureInWindow || !hook?.getFiberRoots) return { texts: [], total: 0 };
29
+ const texts = [];
30
+ const content = (fiber) => {
31
+ let value = '';
32
+ for (let child = fiber.child; child; child = child.sibling) {
33
+ value += typeof child.memoizedProps === 'string' || typeof child.memoizedProps === 'number'
34
+ ? String(child.memoizedProps) : content(child);
35
+ }
36
+ return value;
37
+ };
38
+ const visit = (fiber) => {
39
+ for (; fiber; fiber = fiber.sibling) {
40
+ const testId = fiber.memoizedProps?.testID;
41
+ if (fiber.type === 'RCTText' && typeof testId === 'string' && testId.startsWith('perps-') && fiber.stateNode?.node) {
42
+ let bounds;
43
+ ui.measureInWindow(fiber.stateNode.node, (x, y, width, height) => { bounds = { x, y, width, height }; });
44
+ if (bounds) texts.push({ testId, label: content(fiber), bounds });
45
+ }
46
+ visit(fiber.child);
47
+ }
48
+ };
49
+ const roots = new Set();
50
+ for (const id of hook.renderers.keys()) hook.getFiberRoots(id)?.forEach((root) => roots.add(root));
51
+ roots.forEach((root) => visit(root.current));
52
+ return { texts: texts.slice(0, ${TEXT_TEST_ID_LIMIT}), total: texts.length };
53
+ })()`;
19
54
 
20
55
  export async function observeNativeUi(payload, context) {
21
56
  await hideAccessibilityMaskingHud(payload, context);
@@ -39,7 +74,26 @@ export async function observeNativeUi(payload, context) {
39
74
  warnings.push({ ref: 'ui.screen', message: `Navigation route unavailable: ${error.message}` });
40
75
  }
41
76
  }
42
- const hierarchy = await readNativeHierarchy(payload, context);
77
+ let textTestIds = [];
78
+ if (
79
+ supported.includes('ui.visible') &&
80
+ resolvePlatform(record(payload?.node), env) === 'ios' &&
81
+ !isOpaqueRuntime(env) && (env.METRO_PORT || env.WATCHER_PORT)
82
+ ) {
83
+ try {
84
+ const read = await bridgeCommand(routeInput, ['eval', IOS_TEXT_TEST_IDS]);
85
+ textTestIds = Array.isArray(read?.texts) ? read.texts : [];
86
+ if (read?.total > textTestIds.length) {
87
+ warnings.push({
88
+ ref: 'ui.visible',
89
+ message: `Text test IDs truncated: read ${textTestIds.length} of ${read.total}.`,
90
+ });
91
+ }
92
+ } catch (error) {
93
+ warnings.push({ ref: 'ui.visible', message: `Text test IDs unavailable: ${error.message}` });
94
+ }
95
+ }
96
+ const hierarchy = await readNativeHierarchy(payload, context, textTestIds);
43
97
  if (routeBefore?.name) {
44
98
  const routeAfter = await bridgeCommand(routeInput, ['get-route']);
45
99
  if (routeAfter?.name !== routeBefore.name || routeAfter?.key !== routeBefore.key) {
@@ -85,7 +139,7 @@ export async function hideAccessibilityMaskingHud(payload, context) {
85
139
  }
86
140
  }
87
141
 
88
- async function readNativeHierarchy(payload, context) {
142
+ async function readNativeHierarchy(payload, context, textTestIds) {
89
143
  const node = record(payload?.node);
90
144
  const env = {
91
145
  ...process.env,
@@ -98,13 +152,13 @@ async function readNativeHierarchy(payload, context) {
98
152
  const appLabel = nativeAppLabel(env);
99
153
  const hierarchy = platform === 'android'
100
154
  ? await readAndroidHierarchy(node, env)
101
- : await readIosHierarchy(node, env);
155
+ : await readIosHierarchy(node, env, textTestIds);
102
156
  // No screen name and no labelled item: name the screen after the app.
103
157
  hierarchy.screen.name ??= appLabel;
104
158
  return hierarchy;
105
159
  }
106
160
 
107
- async function readIosHierarchy(node, env) {
161
+ async function readIosHierarchy(node, env, textTestIds) {
108
162
  const idbPath = resolveMobileToolPath('idb');
109
163
  if (!idbPath) {
110
164
  throw new Error(
@@ -123,7 +177,7 @@ async function readIosHierarchy(node, env) {
123
177
  },
124
178
  );
125
179
  const parsed = parseJsonOutput(stdout);
126
- const hierarchy = flattenIosHierarchy(parsed);
180
+ const hierarchy = flattenIosHierarchy(parsed, textTestIds);
127
181
  if (hierarchy.nodeCount === 0) {
128
182
  throw new Error('idb returned an empty accessibility hierarchy.');
129
183
  }
@@ -364,7 +418,12 @@ function parseJsonOutput(output) {
364
418
  }
365
419
  }
366
420
 
367
- function flattenIosHierarchy(value) {
421
+ function flattenIosHierarchy(value, textTestIds = []) {
422
+ const texts = (Array.isArray(textTestIds) ? textTestIds : []).map((entry) => ({
423
+ testId: text(entry?.testId),
424
+ label: text(entry?.label),
425
+ bounds: normalizeBounds(entry?.bounds),
426
+ })).filter((entry) => entry.testId && entry.label && entry.bounds);
368
427
  const visible = [];
369
428
  const hidden = [];
370
429
  let nodeCount = 0;
@@ -382,9 +441,10 @@ function flattenIosHierarchy(value) {
382
441
  text(item.role_description) ?? text(item.className);
383
442
  const label = text(item.label) ?? text(item.AXLabel) ?? text(item.name) ??
384
443
  text(item.title);
385
- const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
386
- text(item.AXUniqueId) ?? text(item.id);
387
444
  const bounds = normalizeBounds(item.frame ?? item.AXFrame ?? item.bounds);
445
+ const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
446
+ text(item.AXUniqueId) ?? text(item.id) ??
447
+ (label && hasVisibleBounds(bounds, screenBounds) ? textTestId(texts, label, bounds) : undefined);
388
448
  if (isApplicationRole(role)) {
389
449
  screenName ??= label;
390
450
  screenBounds ??= bounds;
@@ -466,6 +526,17 @@ function parseAndroidHierarchy(output) {
466
526
  return { visible, hidden, nodeCount, screenName };
467
527
  }
468
528
 
529
+ // Bind an exact label and size to one testID. Origins are not compared: Fabric
530
+ // frames omit native screen offsets such as a modal card. A same-text twin with
531
+ // another size (the collapsed header price) or another testID stays unbound.
532
+ function textTestId(texts, label, bounds) {
533
+ const testIds = new Set(texts
534
+ .filter((entry) => entry.label === label &&
535
+ ['width', 'height'].every((key) => Math.abs(entry.bounds[key] - bounds[key]) <= 1))
536
+ .map((entry) => entry.testId));
537
+ return testIds.size === 1 ? [...testIds][0] : undefined;
538
+ }
539
+
469
540
  function isApplicationRole(role) {
470
541
  return role?.toLowerCase().includes('application') ?? false;
471
542
  }
@@ -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.0",
3
+ "version": "0.86.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"