@deeeed/metamask-harness 0.38.1 → 0.40.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 (104) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +27 -17
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/assets/open_details.mjs +30 -0
  20. package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
  21. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  22. package/library/actions/extension/networks/add_custom.mjs +4 -0
  23. package/library/actions/extension/networks/custom_network.mjs +48 -1
  24. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  25. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  26. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  27. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  28. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  29. package/library/actions/extension/perps/perps.mjs +1029 -363
  30. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  31. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  32. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  33. package/library/actions/extension/perps/search_markets.mjs +32 -0
  34. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  35. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  36. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  37. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  38. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  39. package/library/actions/extension/platform/cdp.mjs +88 -9
  40. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  41. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  42. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  43. package/library/actions/extension/ui/navigate.mjs +45 -5
  44. package/library/actions/extension/wallet/import.mjs +70 -29
  45. package/library/actions/extension/wallet/lock.mjs +62 -5
  46. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  47. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  48. package/library/actions/mobile/assets/open_details.mjs +43 -0
  49. package/library/actions/mobile/assets/read_details.mjs +96 -0
  50. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  51. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  52. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  53. package/library/actions/mobile/networks/network-management.mjs +132 -0
  54. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  55. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  56. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  57. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  58. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  59. package/library/actions/mobile/platform/native-session.mjs +59 -0
  60. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  61. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  62. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  63. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  64. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  65. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  66. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  67. package/library/actions/mobile/ui/native-navigation.mjs +120 -23
  68. package/library/actions/mobile/ui/navigate.mjs +13 -2
  69. package/library/actions/mobile/wallet/import.mjs +17 -10
  70. package/library/actions/mobile/wallet/lock.mjs +14 -0
  71. package/library/actions/mobile/wallet/native-ui.mjs +88 -15
  72. package/library/actions/shared/perps/visible-state.mjs +33 -11
  73. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  74. package/library/manifests/extension.action-manifest.json +1707 -1241
  75. package/library/manifests/mobile.action-manifest.json +308 -1
  76. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  77. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  78. package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
  79. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  80. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  81. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  82. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  83. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  84. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  85. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  86. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  87. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  88. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  89. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  90. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  91. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  92. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  93. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  94. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  95. package/library/recipes/wallet/import.recipe.json +4 -14
  96. package/package.json +1 -1
  97. package/library/actions/extension/assets/finish_send.mjs +0 -128
  98. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  99. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  100. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  101. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  102. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  103. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  104. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -1,7 +1,29 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import { dataTestId, marketSymbol, normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
+ import {
4
+ assertTrustedExternalMutationTarget,
5
+ externalMutationNotDispatched,
6
+ readExternalMutationReceipt,
7
+ readPriorMutationReceipt,
8
+ recordExternalMutationCleanupCandidate,
9
+ runGuardedExternalMutation,
10
+ trustedExternalMutationBudget,
11
+ trustedExternalMutationFundingRequirement,
12
+ trustedExternalMutationIdentity,
13
+ withExternalMutationRecovery,
14
+ } from './mutation-receipt.mjs';
3
15
  import { readSnapshot } from './read_snapshot.mjs';
4
16
  import { readPerpsRuntimeStateExpression } from './state.mjs';
17
+ import {
18
+ orderIdentityMatches,
19
+ parseOrderCard,
20
+ parsePositionDetail,
21
+ parsePositiveBaseUnits,
22
+ parseUsdBaseUnits,
23
+ positionIdentityMatches,
24
+ withinBaseUnitTolerance,
25
+ waitForStableGlobalPerpsHome,
26
+ } from './visible-mutation-identity.mjs';
5
27
 
6
28
  function sleep(ms) {
7
29
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -167,37 +189,121 @@ export async function assertOrders(input, expectedOpen) {
167
189
  export async function closePositions(input) {
168
190
  requireExplicitSelection(input);
169
191
  return withExtensionPage(input, async (page) => {
170
- const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
171
192
  const state = await page.readPositions();
172
193
  if (!state.available) throw new Error('Background Perps API unavailable; cannot verify or close live positions.');
173
194
  const matching = selectedItems(input, state.positions);
174
195
  const symbols = uniqueSymbols(matching.map(symbolForItem));
175
196
  if (symbols.length === 0) return { action: input.action, closed: false, matchingCount: 0, symbols, reason: 'no matching open positions' };
176
- const closeResult = { success: true, successCount: 0, failureCount: 0, results: [] };
177
- for (const position of matching) {
178
- const symbol = symbolForItem(position);
179
- const currentPrice = await readCurrentMarketPrice(page, symbol);
180
- const positionSize = Number(position?.size ?? position?.szi);
181
- if (!Number.isFinite(positionSize) || positionSize === 0) {
182
- closeResult.success = false;
183
- closeResult.failureCount += 1;
184
- closeResult.results.push({ symbol, success: false, error: `Invalid position size for ${symbol}: ${String(position?.size ?? position?.szi)}` });
185
- continue;
197
+ throw new Error(
198
+ 'metamask.perps.close_positions cannot dispatch through background state hooks. ' +
199
+ 'Use the exact receipt-bound metamask.perps.close_visible_position action.',
200
+ );
201
+ });
202
+ }
203
+
204
+ async function closeOrderThroughBackground(input, page, timeoutMs) {
205
+ await requirePerpsTradingContext(input, page);
206
+ const symbol = marketSymbol(input);
207
+ const side = String(input.node?.side ?? '').toLowerCase();
208
+ const amount = String(input.node?.amount ?? '');
209
+ const leverage = Number(input.node?.leverage);
210
+ const limitPrice = String(input.node?.limit_price ?? '');
211
+ const orderId = String(input.node?.order_id ?? '');
212
+ const roundingToleranceBaseUnits = String(input.node?.identity_rounding_tolerance_base_units ?? '10000');
213
+ const expected = {
214
+ market: symbol,
215
+ side,
216
+ orderType: 'limit',
217
+ notionalBaseUnits: `${BigInt(trustedExternalMutationIdentity(input, page, {
218
+ operation: 'perps-cancel-order',
219
+ provider: input.node?.provider,
220
+ network: input.node?.network,
221
+ market: symbol,
222
+ side,
223
+ amount,
224
+ leverage,
225
+ orderType: 'limit',
226
+ positionMode: 'new',
227
+ limitPrice,
228
+ offsetPct: input.node?.offset_pct ?? null,
229
+ partialCloseAmount: null,
230
+ externalOrderId: orderId,
231
+ roundingToleranceBaseUnits,
232
+ }).amountBaseUnits) * BigInt(leverage)}`,
233
+ limitPriceBaseUnits: parseUsdBaseUnits(limitPrice),
234
+ roundingToleranceBaseUnits,
235
+ };
236
+ const identity = trustedExternalMutationIdentity(input, page, {
237
+ operation: 'perps-cancel-order',
238
+ market: symbol,
239
+ side,
240
+ provider: input.node?.provider,
241
+ network: input.node?.network,
242
+ amount,
243
+ leverage,
244
+ orderType: 'limit',
245
+ positionMode: 'new',
246
+ limitPrice,
247
+ offsetPct: input.node?.offset_pct ?? null,
248
+ partialCloseAmount: null,
249
+ externalOrderId: orderId,
250
+ roundingToleranceBaseUnits,
251
+ });
252
+ const fundingBalance = await requireCurrentVisibleFundingBalance(input, page, timeoutMs);
253
+ const existing = await readExternalMutationReceipt(input, identity);
254
+ const exact = (await readOpenOrders(page))
255
+ .map(backgroundOrderObservation)
256
+ .filter((order) => order?.id === orderId && backgroundOrderMatches(order, expected));
257
+ if (!existing && exact.length !== 1) {
258
+ throw new Error('Exact authorized background order is unavailable for cancellation.');
259
+ }
260
+ if (existing?.record?.facts?.order?.id !== orderId) {
261
+ if (existing) throw new Error('Background cancellation receipt is not bound to the requested order.');
262
+ }
263
+ const order = exact[0] ?? existing?.record?.facts?.order;
264
+ const mutation = await runGuardedExternalMutation({
265
+ input,
266
+ identity,
267
+ publicationFacts: { order, fundingBalance },
268
+ dispatch: async () => {
269
+ try {
270
+ await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
271
+ await requirePerpsTradingContext(input, page);
272
+ const current = (await readOpenOrders(page))
273
+ .map(backgroundOrderObservation)
274
+ .filter((candidate) => candidate?.id === orderId && backgroundOrderMatches(candidate, expected));
275
+ if (current.length !== 1 || JSON.stringify(current[0]) !== JSON.stringify(order)) {
276
+ throw new Error('Exact authorized background order changed before cancellation.');
277
+ }
278
+ assertTrustedExternalMutationTarget(input, page, identity);
279
+ } catch (error) {
280
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
186
281
  }
187
- const result = await closePositionWithFreshPrice(page, input, position, symbol);
188
- const success = result?.success === true;
189
- closeResult.results.push({ symbol, success, result });
190
- if (success) closeResult.successCount += 1;
191
- else {
192
- closeResult.success = false;
193
- closeResult.failureCount += 1;
282
+ const result = await requestBackground(page, 'perpsCancelOrder', [{ orderId, symbol }]);
283
+ if (result?.success === false) throw new Error(result?.error || 'perpsCancelOrder failed.');
284
+ },
285
+ observe: async () => {
286
+ const deadline = Date.now() + timeoutMs;
287
+ while (Date.now() <= deadline) {
288
+ const present = (await readOpenOrders(page))
289
+ .map(backgroundOrderObservation)
290
+ .some((candidate) => candidate?.id === orderId);
291
+ if (!present) return { success: true, canceledOrder: order };
292
+ await sleep(250);
194
293
  }
195
- }
196
- const after = await waitForPositionsAbsent(page, symbols, timeoutMs);
197
- const stillOpen = after.positions.filter((position) => symbols.includes(symbolForItem(position)));
198
- if (stillOpen.length > 0) throw new Error(`Expected selected positions to close after perpsClosePosition. Result: ${JSON.stringify(closeResult)}`);
199
- return { action: input.action, closed: true, matchingCount: matching.length, symbols, closeResult, proofPath: 'background-perpsClosePosition' };
294
+ throw new Error('Exact background order did not become absent after cancellation.');
295
+ },
200
296
  });
297
+ return {
298
+ action: input.action,
299
+ canceled: true,
300
+ matchingCount: 1,
301
+ symbols: [symbol],
302
+ order,
303
+ receipt: mutation.receipt,
304
+ resumed: mutation.resumed,
305
+ proofPath: 'background-receipt-bound',
306
+ };
201
307
  }
202
308
 
203
309
  export async function closeOrders(input) {
@@ -207,75 +313,176 @@ export async function closeOrders(input) {
207
313
  const hasBackgroundRequest = await page.evaluate(
208
314
  `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
209
315
  );
316
+ if (hasBackgroundRequest) return closeOrderThroughBackground(input, page, timeoutMs);
210
317
  if (!hasBackgroundRequest) {
211
318
  if (input.node?.confirm_live !== true) {
212
319
  throw new Error('metamask.perps.close_orders requires confirm_live=true for visible live cancellation.');
213
320
  }
214
- const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
215
- if (input.node?.side != null || selector.side != null) {
216
- throw new Error('Visible Perps order cancellation cannot prove an order side. Next: select one market without side or use a runtime with background state hooks.');
217
- }
218
321
  const symbols = configuredSymbols(input, []);
219
322
  if (symbols.length !== 1) {
220
323
  throw new Error('Visible Perps order cancellation requires exactly one market.');
221
324
  }
222
325
  const symbol = symbols[0];
223
- await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
224
- const orderSelector = '[data-testid^="order-card-"]';
225
- await page.waitForSelector(orderSelector, { timeoutMs });
226
- let canceled = 0;
227
- while (await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(orderSelector)}))`)) {
228
- const beforeCount = await page.evaluate(`document.querySelectorAll(${JSON.stringify(orderSelector)}).length`);
229
- await page.click(orderSelector);
326
+ const side = String(input.node?.side ?? '').toLowerCase();
327
+ const amount = String(input.node?.amount ?? '');
328
+ const leverage = Number(input.node?.leverage);
329
+ const limitPrice = String(input.node?.limit_price ?? '');
330
+ const roundingToleranceBaseUnits = String(input.node?.identity_rounding_tolerance_base_units ?? '10000');
331
+ if (!/^(?:long|short)$/u.test(side) || !Number.isInteger(leverage)
332
+ || !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(limitPrice)) {
333
+ throw new Error('Visible Perps cancellation requires side, amount, leverage, and explicit limit_price identity.');
334
+ }
335
+ const operation = {
336
+ operation: 'perps-cancel-order',
337
+ provider: input.node?.provider,
338
+ network: input.node?.network,
339
+ market: symbol,
340
+ side,
341
+ amount,
342
+ leverage,
343
+ orderType: 'limit',
344
+ positionMode: 'new',
345
+ limitPrice,
346
+ offsetPct: input.node?.offset_pct ?? null,
347
+ partialCloseAmount: null,
348
+ roundingToleranceBaseUnits,
349
+ };
350
+ const priorLookupIdentity = trustedExternalMutationIdentity(input, page, operation);
351
+ const priorPlace = await readPriorMutationReceipt(
352
+ input,
353
+ priorLookupIdentity,
354
+ 'metamask.perps.place_order',
355
+ );
356
+ const expectedOrder = {
357
+ market: symbol,
358
+ side,
359
+ orderType: 'limit',
360
+ notionalBaseUnits: `${BigInt(priorLookupIdentity.amountBaseUnits) * BigInt(leverage)}`,
361
+ limitPriceBaseUnits: parseUsdBaseUnits(limitPrice),
362
+ roundingToleranceBaseUnits,
363
+ };
364
+ const cleanupCandidate = priorPlace.record?.state === 'observed-policy-violation'
365
+ ? priorPlace.record.cleanupCandidate
366
+ : null;
367
+ const cleanupOrder = cleanupCandidate?.order;
368
+ const cleanupMode = Boolean(cleanupCandidate)
369
+ && cleanupCandidate.orderId === cleanupOrder?.id
370
+ && cleanupCandidate.market === symbol
371
+ && cleanupCandidate.side === side
372
+ && cleanupCandidate.orderType === 'limit'
373
+ && Number(cleanupCandidate.leverage) === leverage
374
+ && String(cleanupCandidate.limitPriceBaseUnits) === String(expectedOrder.limitPriceBaseUnits)
375
+ && String(cleanupCandidate.requestedNotionalBaseUnits) === expectedOrder.notionalBaseUnits
376
+ && BigInt(cleanupCandidate.observedNotionalBaseUnits) > BigInt(expectedOrder.notionalBaseUnits)
377
+ && BigInt(cleanupCandidate.overageBaseUnits)
378
+ === BigInt(cleanupCandidate.observedNotionalBaseUnits) - BigInt(expectedOrder.notionalBaseUnits);
379
+ const priorOrder = cleanupMode ? cleanupOrder : priorPlace.record?.observation?.order;
380
+ if (!priorOrder?.id || !(cleanupMode || orderIdentityMatches(priorOrder, expectedOrder))) {
381
+ throw new Error('Prior place receipt has no exact visible limit-order identity to cancel.');
382
+ }
383
+ const identity = trustedExternalMutationIdentity(input, page, {
384
+ ...operation,
385
+ visibleOrderId: priorOrder.id,
386
+ });
387
+ const existingReceipt = await readExternalMutationReceipt(input, identity);
388
+ let fundingBalance = null;
389
+ let tradingContext = null;
390
+ if (!existingReceipt) {
391
+ fundingBalance = await requireCurrentVisibleFundingBalance(input, page, timeoutMs);
392
+ tradingContext = await requirePerpsTradingContext(input, page);
393
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
394
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs });
395
+ const exact = (await readVisibleOrderCards(page, symbol))
396
+ .filter((order) => order.id === priorOrder.id && (cleanupMode
397
+ ? cleanupCandidateIdentityMatches(order, expectedOrder)
398
+ && String(order.notionalBaseUnits) === String(cleanupCandidate.observedNotionalBaseUnits)
399
+ : orderIdentityMatches(order, priorOrder)));
400
+ if (exact.length !== 1) throw new Error('Exact authorized visible order is unavailable for cancellation.');
401
+ await page.click(`[data-testid=${JSON.stringify(priorOrder.id)}]`);
230
402
  await page.waitForSelector(dataTestId('perps-cancel-order-modal'), { timeoutMs });
231
- await page.click(dataTestId('perps-cancel-order-button'));
232
- canceled += 1;
403
+ }
404
+ const mutation = await runGuardedExternalMutation({
405
+ input,
406
+ identity,
407
+ publicationFacts: {
408
+ order: priorOrder,
409
+ ...(fundingBalance ? { fundingBalance } : {}),
410
+ ...(tradingContext ? { tradingContext } : {}),
411
+ ...(cleanupMode ? { cleanupMode: 'observed-policy-violation', cleanupCandidate } : {}),
412
+ },
413
+ dispatch: async () => {
414
+ let dispatchTarget;
415
+ try {
416
+ await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
417
+ await requirePerpsTradingContext(input, page);
418
+ assertTrustedExternalMutationTarget(input, page, identity);
419
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
420
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs });
421
+ const exact = (await readVisibleOrderCards(page, symbol))
422
+ .filter((order) => order.id === priorOrder.id && (cleanupMode
423
+ ? cleanupCandidateIdentityMatches(order, expectedOrder)
424
+ && String(order.notionalBaseUnits) === String(cleanupCandidate.observedNotionalBaseUnits)
425
+ : orderIdentityMatches(order, priorOrder)));
426
+ if (exact.length !== 1) throw new Error('Exact authorized visible order changed before cancellation.');
427
+ await page.click(`[data-testid=${JSON.stringify(priorOrder.id)}]`);
428
+ await page.waitForSelector(dataTestId('perps-cancel-order-modal'), { timeoutMs });
429
+ dispatchTarget = await page.evaluate(`(() => {
430
+ const element = document.querySelector('[data-testid="perps-cancel-order-button"]');
431
+ if (!element || element.disabled || element.getAttribute('aria-disabled') === 'true') {
432
+ throw new Error('Visible Perps cancel control is unavailable.');
433
+ }
434
+ element.scrollIntoView({ block: 'center', inline: 'center' });
435
+ const rect = element.getBoundingClientRect();
436
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
437
+ })()`);
438
+ assertTrustedExternalMutationTarget(input, page, identity);
439
+ } catch (error) {
440
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
441
+ }
442
+ await page.session.call('Input.dispatchMouseEvent', {
443
+ type: 'mousePressed', x: dispatchTarget.x, y: dispatchTarget.y,
444
+ button: 'left', buttons: 1, clickCount: 1,
445
+ });
446
+ await page.session.call('Input.dispatchMouseEvent', {
447
+ type: 'mouseReleased', x: dispatchTarget.x, y: dispatchTarget.y,
448
+ button: 'left', buttons: 0, clickCount: 1,
449
+ });
450
+ },
451
+ observe: async () => {
233
452
  const deadline = Date.now() + timeoutMs;
453
+ let absentSince = null;
234
454
  while (Date.now() < deadline) {
235
- const state = await page.evaluate(`({
236
- modal: Boolean(document.querySelector(${JSON.stringify(dataTestId('perps-cancel-order-modal'))})),
237
- orders: document.querySelectorAll(${JSON.stringify(orderSelector)}).length,
238
- })`);
239
- if (!state.modal && state.orders < beforeCount) break;
455
+ const present = (await readVisibleOrderCards(page, symbol)).some((order) => order.id === priorOrder.id);
456
+ if (!present) {
457
+ absentSince ??= Date.now();
458
+ if (Date.now() - absentSince >= 1_000) {
459
+ await page.navigateHash('#/perps-home');
460
+ const cleanup = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: true });
461
+ return {
462
+ success: true,
463
+ canceledOrder: priorOrder,
464
+ cleanup: { positionCount: 0, orderCount: 0, balanceBaseUnits: cleanup.balanceBaseUnits },
465
+ };
466
+ }
467
+ } else absentSince = null;
240
468
  await sleep(250);
241
469
  }
242
- if (canceled >= 100) throw new Error('Visible Perps order cancellation exceeded 100 orders.');
243
- }
470
+ throw new Error('Exact authorized order did not become stably absent after cancellation.');
471
+ },
472
+ });
244
473
  return {
245
474
  action: input.action,
246
- canceled: canceled > 0,
247
- matchingCount: canceled,
475
+ canceled: true,
476
+ matchingCount: 1,
248
477
  symbols: [symbol],
478
+ order: priorOrder,
479
+ receipt: mutation.receipt,
480
+ resumed: mutation.resumed,
481
+ cleanup: mutation.observation.cleanup,
249
482
  proofPath: 'visible-product-controls',
250
483
  };
251
484
  }
252
- const before = await readOpenOrders(page);
253
- const matching = selectedItems(input, before);
254
- const symbols = uniqueSymbols(matching.map(symbolForItem));
255
- if (symbols.length === 0) return { action: input.action, canceled: false, matchingCount: 0, symbols, result: null };
256
- const result = { success: true, successCount: 0, failureCount: 0, results: [] };
257
- for (const order of matching) {
258
- const orderId = order?.orderId ?? order?.id;
259
- const symbol = symbolForItem(order);
260
- if (!orderId || !symbol) {
261
- result.success = false;
262
- result.failureCount += 1;
263
- result.results.push({ orderId: orderId ?? null, symbol: symbol || null, success: false, error: 'missing orderId or symbol' });
264
- continue;
265
- }
266
- const cancelResult = await requestBackground(page, 'perpsCancelOrder', [{ orderId: String(orderId), symbol }]);
267
- const success = cancelResult?.success !== false;
268
- result.results.push({ orderId: String(orderId), symbol, success, result: cancelResult });
269
- if (success) result.successCount += 1;
270
- else {
271
- result.success = false;
272
- result.failureCount += 1;
273
- }
274
- }
275
- const after = await waitForOrdersAbsent(page, symbols, timeoutMs);
276
- const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
277
- if (stillOpen.length > 0) throw new Error(`Expected selected Perps orders to cancel, but ${stillOpen.length} are still open.`);
278
- return { action: input.action, canceled: true, matchingCount: matching.length, symbols, result, proofPath: 'background-perpsCancelOrder' };
485
+ throw new Error('Visible Perps cancellation did not return a terminal result.');
279
486
  });
280
487
  }
281
488
 
@@ -290,47 +497,6 @@ async function requestBackground(page, method, args, timeoutMs = 20000) {
290
497
  return result;
291
498
  }
292
499
 
293
- async function triggerBackground(page, method, args) {
294
- return page.evaluate(`(() => {
295
- const request = globalThis.stateHooks?.submitRequestToBackground;
296
- if (typeof request !== 'function') throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot call ${method}.');
297
- const pending = request(${JSON.stringify(method)}, ${JSON.stringify(args)});
298
- Promise.resolve(pending).catch(() => {});
299
- return { requested: true };
300
- })()`);
301
- }
302
-
303
- function isTransientClosePriceError(result) {
304
- const message = String(result?.error ?? result?.message ?? '');
305
- // Retry local pricing/slippage races only. Provider-level oracle rejections
306
- // such as "Price too far from oracle" are surfaced as real market failures.
307
- return /IOC_CANCEL|Slippage/i.test(message);
308
- }
309
-
310
- async function closePositionWithFreshPrice(page, input, position, symbol) {
311
- const maxAttempts = Number(input.node?.close_attempts ?? 3);
312
- const attempts = [];
313
- let lastResult = null;
314
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
315
- const currentPrice = await readCurrentMarketPrice(page, symbol, input.node?.current_price);
316
- const params = {
317
- symbol,
318
- size: String(input.node?.size ?? ''),
319
- orderType: 'market',
320
- priceAtCalculation: currentPrice,
321
- maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
322
- position,
323
- };
324
- const result = await requestBackground(page, 'perpsClosePosition', [params]);
325
- attempts.push({ attempt, currentPrice, result });
326
- lastResult = result;
327
- if (result?.success === true) return { ...result, attempts, params };
328
- if (!isTransientClosePriceError(result) || attempt === maxAttempts) break;
329
- await sleep(Number(input.node?.close_retry_delay_ms ?? 1000));
330
- }
331
- return { ...(lastResult && typeof lastResult === 'object' ? lastResult : { success: false, error: String(lastResult) }), attempts };
332
- }
333
-
334
500
  async function readCurrentMarketPrice(page, symbol, explicitPrice) {
335
501
  if (explicitPrice !== undefined && explicitPrice !== null && explicitPrice !== '') {
336
502
  const parsed = Number(String(explicitPrice).replace(/[$,]/g, ''));
@@ -385,6 +551,146 @@ async function readOpenOrders(page) {
385
551
  return (await readOpenOrdersState(page)).orders;
386
552
  }
387
553
 
554
+ function backgroundOrderObservation(order) {
555
+ const id = String(order?.orderId ?? order?.id ?? '');
556
+ const market = symbolForItem(order);
557
+ const side = sideForItem(order);
558
+ const orderType = String(order?.orderType?.type ?? order?.orderType ?? order?.type ?? '').toLowerCase();
559
+ const limitPriceBaseUnits = parseUsdBaseUnits(
560
+ order?.price ?? order?.limitPrice ?? order?.limitPx ?? order?.px,
561
+ );
562
+ const size = Number(order?.size ?? order?.sz ?? order?.szi);
563
+ const price = limitPriceBaseUnits == null ? Number.NaN : Number(limitPriceBaseUnits) / 1_000_000;
564
+ const notional = size * price;
565
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,180}$/u.test(id)
566
+ || !market || !/^(?:long|short)$/u.test(side) || orderType !== 'limit'
567
+ || limitPriceBaseUnits == null || !Number.isFinite(notional) || notional <= 0) return null;
568
+ return {
569
+ id,
570
+ market,
571
+ side,
572
+ orderType,
573
+ limitPriceBaseUnits,
574
+ notionalBaseUnits: `${Math.round(notional * 1_000_000)}`,
575
+ };
576
+ }
577
+
578
+ function backgroundOrderMatches(observed, expected) {
579
+ return Boolean(observed)
580
+ && observed.market === expected.market
581
+ && observed.side === expected.side
582
+ && observed.orderType === expected.orderType
583
+ && withinBaseUnitTolerance(
584
+ observed.notionalBaseUnits,
585
+ expected.notionalBaseUnits,
586
+ expected.roundingToleranceBaseUnits,
587
+ )
588
+ && withinBaseUnitTolerance(
589
+ observed.limitPriceBaseUnits,
590
+ expected.limitPriceBaseUnits,
591
+ expected.roundingToleranceBaseUnits,
592
+ );
593
+ }
594
+
595
+ function backgroundPositionObservation(position) {
596
+ const market = symbolForItem(position);
597
+ const side = sideForItem(position);
598
+ const leverage = Number(position?.leverage?.value ?? position?.leverage ?? position?.lev);
599
+ const size = Number(position?.size ?? position?.szi);
600
+ let marginBaseUnits = parseUsdBaseUnits(
601
+ position?.marginUsed ?? position?.margin ?? position?.marginValue,
602
+ );
603
+ if (marginBaseUnits == null) {
604
+ const positionValueBaseUnits = parseUsdBaseUnits(
605
+ position?.positionValue ?? position?.notional ?? position?.value,
606
+ );
607
+ if (positionValueBaseUnits != null && Number.isInteger(leverage) && leverage > 0) {
608
+ marginBaseUnits = `${BigInt(positionValueBaseUnits) / BigInt(leverage)}`;
609
+ }
610
+ }
611
+ if (!market || !/^(?:long|short)$/u.test(side) || !Number.isInteger(leverage)
612
+ || leverage < 1 || !Number.isFinite(size) || size === 0 || marginBaseUnits == null
613
+ || BigInt(marginBaseUnits) <= 0n) return null;
614
+ return { market, side, leverage, size: String(position?.size ?? position?.szi), marginBaseUnits };
615
+ }
616
+
617
+ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
618
+ const deadline = Date.now() + timeoutMs;
619
+ while (Date.now() <= deadline) {
620
+ if (expected.orderType === 'limit') {
621
+ const matches = (await readOpenOrders(page))
622
+ .map(backgroundOrderObservation)
623
+ .filter((order) => order && !baselineIds.has(order.id) && backgroundOrderMatches(order, expected));
624
+ if (matches.length === 1) return { success: true, order: matches[0] };
625
+ if (matches.length > 1) throw new Error('Background order postcondition matched more than one new order.');
626
+ } else {
627
+ const state = await page.readPositions();
628
+ if (!state.available) throw new Error('Background position postcondition is unavailable.');
629
+ const matches = state.positions
630
+ .map(backgroundPositionObservation)
631
+ .filter((position) => position
632
+ && position.market === expected.market
633
+ && position.side === expected.side
634
+ && position.leverage === expected.leverage
635
+ && withinBaseUnitTolerance(
636
+ position.marginBaseUnits,
637
+ expected.amountBaseUnits,
638
+ expected.roundingToleranceBaseUnits,
639
+ ));
640
+ if (matches.length === 1) return { success: true, position: matches[0] };
641
+ if (matches.length > 1) throw new Error('Background position postcondition matched more than one position.');
642
+ }
643
+ await sleep(250);
644
+ }
645
+ throw new Error('Exact background Perps placement postcondition was not proven.');
646
+ }
647
+
648
+ async function dispatchBackgroundPlace(page, {
649
+ amount,
650
+ isBuy,
651
+ leverage,
652
+ limitPrice,
653
+ maxSlippageBps,
654
+ orderType,
655
+ resolvedPrice,
656
+ symbol,
657
+ }) {
658
+ return page.evaluate(`(async () => {
659
+ const request = globalThis.stateHooks?.submitRequestToBackground;
660
+ if (typeof request !== 'function') throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot place live Perps order.');
661
+ const currentPrice = Number(${JSON.stringify(resolvedPrice)});
662
+ const usdAmount = ${JSON.stringify(amount)};
663
+ const leverage = ${JSON.stringify(leverage)};
664
+ const orderType = ${JSON.stringify(orderType)};
665
+ const limitPrice = ${JSON.stringify(limitPrice)};
666
+ const orderParams = orderType === 'limit' ? {
667
+ symbol: ${JSON.stringify(symbol)},
668
+ isBuy: ${JSON.stringify(isBuy)},
669
+ size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
670
+ orderType,
671
+ price: String(limitPrice),
672
+ timeInForce: 'GTC',
673
+ leverage,
674
+ currentPrice,
675
+ priceAtCalculation: currentPrice,
676
+ maxSlippageBps: ${JSON.stringify(maxSlippageBps)},
677
+ } : {
678
+ symbol: ${JSON.stringify(symbol)},
679
+ isBuy: ${JSON.stringify(isBuy)},
680
+ size: ((Number(usdAmount) * Number(leverage)) / currentPrice).toString(),
681
+ orderType,
682
+ leverage,
683
+ currentPrice,
684
+ priceAtCalculation: currentPrice,
685
+ usdAmount,
686
+ maxSlippageBps: ${JSON.stringify(maxSlippageBps)},
687
+ };
688
+ const result = await request('perpsPlaceOrder', [orderParams]);
689
+ if (!result || result.success !== true) throw new Error(result?.error || 'perpsPlaceOrder failed.');
690
+ return { success: true };
691
+ })()`, { awaitPromise: true });
692
+ }
693
+
388
694
  async function readOpenOrdersState(page) {
389
695
  const state = await page.evaluate(`(async () => {
390
696
  const request = globalThis.stateHooks?.submitRequestToBackground;
@@ -500,16 +806,369 @@ async function waitForSelectedOrderState(page, input, expectedOpen, timeoutMs) {
500
806
  }
501
807
  }
502
808
 
809
+ async function readVisiblePositionIdentity(page) {
810
+ const raw = await page.evaluate(`(() => {
811
+ const read = (testId) => {
812
+ const element = document.querySelector('[data-testid="' + testId + '"]');
813
+ if (!element) return null;
814
+ const rect = element.getBoundingClientRect();
815
+ const style = getComputedStyle(element);
816
+ if (rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden') return null;
817
+ return String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 200);
818
+ };
819
+ return {
820
+ pair: read('perps-market-detail-pair'),
821
+ direction: read('perps-position-leverage'),
822
+ margin: read('perps-position-margin-value'),
823
+ size: read('perps-position-size-value'),
824
+ };
825
+ })()`);
826
+ return parsePositionDetail(raw);
827
+ }
828
+
829
+ async function readVisibleOrderCards(page, market) {
830
+ const rows = await page.evaluate(`(() => [...document.querySelectorAll('[data-testid^="order-card-"]')]
831
+ .filter((element) => {
832
+ const rect = element.getBoundingClientRect();
833
+ const style = getComputedStyle(element);
834
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
835
+ })
836
+ .slice(0, 100)
837
+ .map((element) => ({ id: element.getAttribute('data-testid'), text: String(element.innerText || '').slice(0, 1000) })))()`);
838
+ return rows.map((row) => parseOrderCard(row, market)).filter(Boolean);
839
+ }
840
+
841
+ function cleanupCandidateIdentityMatches(order, expected) {
842
+ return Boolean(order)
843
+ && String(order.market).toUpperCase() === String(expected.market).toUpperCase()
844
+ && String(order.side).toLowerCase() === String(expected.side).toLowerCase()
845
+ && String(order.orderType).toLowerCase() === 'limit'
846
+ && BigInt(order.limitPriceBaseUnits) === BigInt(expected.limitPriceBaseUnits)
847
+ && BigInt(order.notionalBaseUnits) > BigInt(expected.notionalBaseUnits);
848
+ }
849
+
850
+ async function observePolicyViolationCleanupCandidate(page, expected, receipt, timeoutMs) {
851
+ const baselineIds = Array.isArray(receipt?.facts?.baselineOrderIds) ? receipt.facts.baselineOrderIds : null;
852
+ if (!baselineIds || baselineIds.length !== 0) return null;
853
+ const deadline = Date.now() + Math.min(timeoutMs, 10_000);
854
+ let stableCandidate = null;
855
+ let stableSince = 0;
856
+ while (Date.now() < deadline) {
857
+ const candidates = (await readVisibleOrderCards(page, expected.market))
858
+ .filter((order) => cleanupCandidateIdentityMatches(order, expected));
859
+ if (candidates.length > 1) throw new Error('More than one visible over-cap order matches the cleanup identity.');
860
+ if (candidates.length === 1) {
861
+ const key = JSON.stringify(candidates[0]);
862
+ if (key !== stableCandidate) {
863
+ stableCandidate = key;
864
+ stableSince = Date.now();
865
+ } else if (Date.now() - stableSince >= 1_000) {
866
+ const order = candidates[0];
867
+ const observedNotionalBaseUnits = String(order.notionalBaseUnits);
868
+ const requestedNotionalBaseUnits = String(expected.notionalBaseUnits);
869
+ return {
870
+ orderId: order.id,
871
+ order,
872
+ market: expected.market,
873
+ side: expected.side,
874
+ orderType: 'limit',
875
+ leverage: expected.leverage,
876
+ limitPriceBaseUnits: String(expected.limitPriceBaseUnits),
877
+ observedNotionalBaseUnits,
878
+ requestedNotionalBaseUnits,
879
+ overageBaseUnits: `${BigInt(observedNotionalBaseUnits) - BigInt(requestedNotionalBaseUnits)}`,
880
+ };
881
+ }
882
+ } else {
883
+ stableCandidate = null;
884
+ }
885
+ await sleep(250);
886
+ }
887
+ return null;
888
+ }
889
+
890
+ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs) {
891
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(expected.market)}`);
892
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
893
+ const deadline = Date.now() + timeoutMs;
894
+ let stableIdentity = null;
895
+ let stableSince = 0;
896
+ let lastObserved = null;
897
+ while (Date.now() < deadline) {
898
+ if (expected.orderType === 'market') {
899
+ const position = await readVisiblePositionIdentity(page);
900
+ let observedNotionalBaseUnits = null;
901
+ if (position) {
902
+ const size = Number(String(position.size).replace(/,/gu, '').match(/[0-9]+(?:\.[0-9]+)?/u)?.[0]);
903
+ const price = await readCurrentMarketPrice(page, expected.market);
904
+ if (Number.isFinite(size) && size > 0) {
905
+ observedNotionalBaseUnits = `${Math.round(size * price * 1_000_000)}`;
906
+ if (BigInt(observedNotionalBaseUnits) > BigInt(expected.maximumNotionalBaseUnits)) {
907
+ throw new Error('Visible Perps position notional exceeds the runner-verified operation budget.');
908
+ }
909
+ }
910
+ }
911
+ lastObserved = {
912
+ position: position ? {
913
+ market: String(position.market).slice(0, 20),
914
+ side: String(position.side).slice(0, 10),
915
+ leverage: Number(position.leverage),
916
+ marginBaseUnits: /^-?\d{1,30}$/u.test(String(position.marginBaseUnits))
917
+ ? String(position.marginBaseUnits)
918
+ : null,
919
+ positiveSize: /[1-9]/u.test(String(position.size).replace(/[^0-9]/gu, '')),
920
+ observedNotionalBaseUnits,
921
+ } : null,
922
+ };
923
+ if (observedNotionalBaseUnits && positionIdentityMatches(position, expected)) {
924
+ const key = JSON.stringify(position);
925
+ if (key !== stableIdentity) {
926
+ stableIdentity = key;
927
+ stableSince = Date.now();
928
+ } else if (Date.now() - stableSince >= 1_000) {
929
+ return {
930
+ success: true,
931
+ position,
932
+ observedNotionalBaseUnits,
933
+ maximumNotionalBaseUnits: expected.maximumNotionalBaseUnits,
934
+ order: null,
935
+ orderId: null,
936
+ };
937
+ }
938
+ } else {
939
+ stableIdentity = null;
940
+ }
941
+ } else {
942
+ const baselineIds = new Set(Array.isArray(receipt?.facts?.baselineOrderIds) ? receipt.facts.baselineOrderIds : []);
943
+ const orders = await readVisibleOrderCards(page, expected.market);
944
+ lastObserved = {
945
+ orders: orders.slice(0, 10).map((order) => ({
946
+ id: /^[A-Za-z0-9:_-]{1,128}$/u.test(String(order.id)) ? String(order.id) : null,
947
+ market: String(order.market).slice(0, 20),
948
+ side: String(order.side).slice(0, 10),
949
+ orderType: String(order.orderType).slice(0, 10),
950
+ notionalBaseUnits: /^\d{1,30}$/u.test(String(order.notionalBaseUnits))
951
+ ? String(order.notionalBaseUnits)
952
+ : null,
953
+ limitPriceBaseUnits: /^\d{1,30}$/u.test(String(order.limitPriceBaseUnits))
954
+ ? String(order.limitPriceBaseUnits)
955
+ : null,
956
+ })),
957
+ };
958
+ const matching = orders
959
+ .filter((order) => !baselineIds.has(order.id) && orderIdentityMatches(order, expected));
960
+ if (matching.length > 1) throw new Error('More than one new visible order matches the authorized identity.');
961
+ if (matching.length === 1) {
962
+ const key = JSON.stringify(matching[0]);
963
+ if (key !== stableIdentity) {
964
+ stableIdentity = key;
965
+ stableSince = Date.now();
966
+ } else if (Date.now() - stableSince >= 1_000) {
967
+ return { success: true, position: null, order: matching[0], orderId: matching[0].id };
968
+ }
969
+ } else {
970
+ stableIdentity = null;
971
+ }
972
+ }
973
+ await sleep(250);
974
+ }
975
+ const identity = expected.orderType === 'market' && expected.matchMargin === false
976
+ ? 'market/side/leverage'
977
+ : 'market/side/amount';
978
+ throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
979
+ }
980
+
981
+ async function placeOrderThroughBackground(input, page, {
982
+ amount,
983
+ expected,
984
+ identity,
985
+ isBuy,
986
+ leverage,
987
+ limitPrice,
988
+ orderType,
989
+ positionMode,
990
+ resolvedPrice,
991
+ submitAttempts,
992
+ symbol,
993
+ }) {
994
+ if (positionMode !== 'new' || submitAttempts !== 1) {
995
+ throw new Error('Background Perps placement requires position_mode=new and submit_attempts=1.');
996
+ }
997
+ const fundingBalance = await requireCurrentVisibleFundingBalance(
998
+ input,
999
+ page,
1000
+ Number(input.node?.timeout_ms ?? 30_000),
1001
+ );
1002
+ const existing = await readExternalMutationReceipt(input, identity);
1003
+ let baselineIds = new Set();
1004
+ if (existing?.record?.facts?.baselineOrderIds) {
1005
+ baselineIds = new Set(existing.record.facts.baselineOrderIds.map(String));
1006
+ } else if (!existing) {
1007
+ if (orderType === 'limit') {
1008
+ baselineIds = new Set((await readOpenOrders(page))
1009
+ .map((order) => String(order?.orderId ?? order?.id ?? ''))
1010
+ .filter(Boolean));
1011
+ } else {
1012
+ const state = await page.readPositions();
1013
+ if (!state.available) throw new Error('Background position baseline is unavailable.');
1014
+ const matching = state.positions
1015
+ .map(backgroundPositionObservation)
1016
+ .filter((position) => position
1017
+ && position.market === symbol && position.side === expected.side);
1018
+ if (matching.length !== 0) {
1019
+ throw new Error('Background Perps placement requires no pre-existing matching position.');
1020
+ }
1021
+ }
1022
+ }
1023
+ const mutation = await runGuardedExternalMutation({
1024
+ input,
1025
+ identity,
1026
+ publicationFacts: { baselineOrderIds: [...baselineIds], fundingBalance },
1027
+ dispatch: async () => {
1028
+ try {
1029
+ await requireCurrentVisibleFundingBalance(
1030
+ input,
1031
+ page,
1032
+ Number(input.node?.timeout_ms ?? 30_000),
1033
+ fundingBalance.observedBaseUnits,
1034
+ );
1035
+ await requirePerpsTradingContext(input, page);
1036
+ assertTrustedExternalMutationTarget(input, page, identity);
1037
+ } catch (error) {
1038
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
1039
+ }
1040
+ return dispatchBackgroundPlace(page, {
1041
+ amount,
1042
+ isBuy,
1043
+ leverage,
1044
+ limitPrice,
1045
+ maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
1046
+ orderType,
1047
+ resolvedPrice,
1048
+ symbol,
1049
+ });
1050
+ },
1051
+ observe: async () => observeBackgroundPlace(
1052
+ page,
1053
+ expected,
1054
+ baselineIds,
1055
+ Number(input.node?.timeout_ms ?? 30_000),
1056
+ ),
1057
+ });
1058
+ return {
1059
+ action: input.action,
1060
+ market: symbol,
1061
+ side: expected.side,
1062
+ orderType,
1063
+ positionMode,
1064
+ amount,
1065
+ leverage,
1066
+ limitPrice,
1067
+ submitted: true,
1068
+ submitAttempts,
1069
+ receipt: mutation.receipt,
1070
+ resumed: mutation.resumed,
1071
+ outcome: mutation.observation,
1072
+ proofPath: 'background-receipt-bound',
1073
+ };
1074
+ }
1075
+
1076
+ export async function requirePerpsTradingContext(input, page) {
1077
+ const provider = String(input.node?.provider ?? '').toLowerCase();
1078
+ const network = String(input.node?.network ?? '').toLowerCase();
1079
+ if (!/^[a-z][a-z0-9_-]{0,31}$/u.test(provider) || !/^(?:mainnet|testnet)$/u.test(network)) {
1080
+ throw new Error('Perps mutation requires explicit provider and network inputs.');
1081
+ }
1082
+ const state = await readPerpsRuntimeState(page);
1083
+ if (!state.available || !state.networkKnown) {
1084
+ throw new Error('Perps mutation cannot verify the current provider and network.');
1085
+ }
1086
+ const actualProvider = String(state.activeProvider || '').toLowerCase();
1087
+ const actualNetwork = state.isTestnet === true ? 'testnet' : 'mainnet';
1088
+ if (actualProvider !== provider || actualNetwork !== network) {
1089
+ throw new Error(
1090
+ `Perps mutation expected ${provider}/${network}, got ${actualProvider || 'unknown'}/${actualNetwork}.`,
1091
+ );
1092
+ }
1093
+ return { provider, network };
1094
+ }
1095
+
1096
+ export async function requireCurrentVisibleFundingBalance(input, page, timeoutMs, expectedBaseUnits) {
1097
+ const requirement = trustedExternalMutationFundingRequirement(input);
1098
+ await page.navigateHash('#/perps-home');
1099
+ const home = await waitForStableGlobalPerpsHome(page, {
1100
+ timeoutMs,
1101
+ stableMs: Math.min(1_000, Math.max(0, timeoutMs - 250)),
1102
+ requireZero: false,
1103
+ });
1104
+ if (BigInt(home.balanceBaseUnits) < BigInt(requirement.minBaseUnits)) {
1105
+ throw new Error('Current visible Perps balance is below the runner-trusted funding minimum.');
1106
+ }
1107
+ if (expectedBaseUnits != null && home.balanceBaseUnits !== expectedBaseUnits) {
1108
+ throw new Error('Current visible Perps balance changed after receipt publication.');
1109
+ }
1110
+ return {
1111
+ source: 'visible-perps-home',
1112
+ requirementId: `${requirement.kind}:${requirement.id}`,
1113
+ minimumBaseUnits: requirement.minBaseUnits,
1114
+ evidenceActualBaseUnits: requirement.actualBaseUnits,
1115
+ observedBaseUnits: home.balanceBaseUnits,
1116
+ };
1117
+ }
1118
+
1119
+ async function requireCurrentOrderEntryFundingBalance(input, page, expectedBaseUnits) {
1120
+ const requirement = trustedExternalMutationFundingRequirement(input);
1121
+ const visible = await page.evaluate(`(() => {
1122
+ const row = document.querySelector('[data-testid="amount-input-available-to-trade-row"]');
1123
+ if (!row) return null;
1124
+ const rect = row.getBoundingClientRect();
1125
+ return {
1126
+ text: String(row.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 200),
1127
+ visible: rect.width > 0 && rect.height > 0,
1128
+ };
1129
+ })()`);
1130
+ const visibleAmount = visible?.visible
1131
+ ? /([0-9][0-9,]*(?:\.[0-9]{1,6})?)\s*USDC/iu.exec(String(visible.text))?.[1]
1132
+ : null;
1133
+ const observedBaseUnits = visibleAmount ? parseUsdBaseUnits(visibleAmount) : null;
1134
+ if (observedBaseUnits == null) {
1135
+ throw new Error('Current visible Perps order-entry balance is unavailable.');
1136
+ }
1137
+ if (BigInt(observedBaseUnits) < BigInt(requirement.minBaseUnits)) {
1138
+ throw new Error('Current visible Perps balance is below the runner-trusted funding minimum.');
1139
+ }
1140
+ if (observedBaseUnits !== expectedBaseUnits) {
1141
+ throw new Error('Current visible Perps balance changed after receipt publication.');
1142
+ }
1143
+ return {
1144
+ source: 'visible-perps-order-entry',
1145
+ requirementId: `${requirement.kind}:${requirement.id}`,
1146
+ minimumBaseUnits: requirement.minBaseUnits,
1147
+ evidenceActualBaseUnits: requirement.actualBaseUnits,
1148
+ observedBaseUnits,
1149
+ };
1150
+ }
1151
+
503
1152
  export async function placeOrder(input) {
504
1153
  requirePlaceOrderInputs(input);
505
1154
  const symbol = marketSymbol(input);
506
1155
  const side = String(input.node.side).toLowerCase();
507
1156
  const amount = String(input.node.amount ?? input.node.notional);
508
- const leverage = Number(input.node?.leverage ?? 3);
1157
+ const leverage = Number(input.node?.leverage ?? 1);
509
1158
  const isBuy = side !== 'short';
510
1159
  const orderType = resolveOrderType(input);
511
1160
  const positionMode = String(input.node?.position_mode ?? 'new').toLowerCase();
512
1161
  const submitAttempts = Number(input.node?.submit_attempts ?? 1);
1162
+ const roundingToleranceBaseUnits = String(input.node?.identity_rounding_tolerance_base_units ?? '10000');
1163
+ if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(roundingToleranceBaseUnits)
1164
+ || BigInt(roundingToleranceBaseUnits) > 1_000_000n) {
1165
+ throw new Error(`${input.action} identity_rounding_tolerance_base_units must be 0..1000000.`);
1166
+ }
1167
+ const explicitLimitPrice = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
1168
+ if (orderType === 'limit' && (explicitLimitPrice == null || String(explicitLimitPrice) === '')) {
1169
+ throw new Error('Funded visible limit orders require an explicit limit_price so resume cannot recompute a changed offset price.');
1170
+ }
1171
+ const stableLimitPrice = orderType === 'limit' ? String(explicitLimitPrice) : null;
513
1172
  if (positionMode !== 'new' && positionMode !== 'modify') {
514
1173
  throw new Error(`${input.action} received unsupported position_mode: ${positionMode}.`);
515
1174
  }
@@ -517,17 +1176,165 @@ export async function placeOrder(input) {
517
1176
  throw new Error(`${input.action} submit_attempts must be 1 or 2.`);
518
1177
  }
519
1178
  return withExtensionPage(input, async (page) => {
1179
+ const identity = trustedExternalMutationIdentity(input, page, {
1180
+ operation: orderType === 'limit' ? 'perps-limit-order' : 'perps-market-order',
1181
+ provider: input.node?.provider,
1182
+ network: input.node?.network,
1183
+ market: symbol,
1184
+ side,
1185
+ amount,
1186
+ leverage,
1187
+ orderType,
1188
+ positionMode,
1189
+ limitPrice: stableLimitPrice,
1190
+ offsetPct: input.node?.offset_pct ?? input.node?.offsetPct ?? null,
1191
+ partialCloseAmount: null,
1192
+ roundingToleranceBaseUnits,
1193
+ });
1194
+ const requestedNotionalBaseUnits = BigInt(identity.amountBaseUnits) * BigInt(leverage);
1195
+ const placementBudget = trustedExternalMutationBudget(input, identity.operation);
1196
+ if (requestedNotionalBaseUnits > BigInt(placementBudget.maxBaseUnits)) {
1197
+ throw new Error('Requested Perps order exposure exceeds the runner-trusted operation cap.');
1198
+ }
1199
+ const expectedVisibleIdentity = {
1200
+ market: symbol,
1201
+ side,
1202
+ leverage,
1203
+ orderType,
1204
+ amountBaseUnits: identity.amountBaseUnits,
1205
+ notionalBaseUnits: `${requestedNotionalBaseUnits}`,
1206
+ limitPriceBaseUnits: stableLimitPrice == null ? null : parseUsdBaseUnits(stableLimitPrice),
1207
+ roundingToleranceBaseUnits,
1208
+ ...(orderType === 'market' ? { matchMargin: false } : {}),
1209
+ ...(orderType === 'market' ? {
1210
+ maximumNotionalBaseUnits: placementBudget.maxBaseUnits,
1211
+ } : {}),
1212
+ };
1213
+ const hasBackgroundRequest = await page.evaluate(
1214
+ `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1215
+ );
1216
+ if (hasBackgroundRequest) {
1217
+ await requirePerpsTradingContext(input, page);
1218
+ const resolvedPrice = await readCurrentMarketPrice(
1219
+ page,
1220
+ symbol,
1221
+ input.node?.current_price ?? input.node?.currentPrice,
1222
+ );
1223
+ const backgroundLimitPrice = orderType === 'limit'
1224
+ ? resolveLimitPrice(input, isBuy, resolvedPrice)
1225
+ : null;
1226
+ return placeOrderThroughBackground(input, page, {
1227
+ amount,
1228
+ expected: {
1229
+ ...expectedVisibleIdentity,
1230
+ limitPriceBaseUnits: backgroundLimitPrice == null ? null : parseUsdBaseUnits(backgroundLimitPrice),
1231
+ },
1232
+ identity,
1233
+ isBuy,
1234
+ leverage,
1235
+ limitPrice: backgroundLimitPrice,
1236
+ orderType,
1237
+ positionMode,
1238
+ resolvedPrice,
1239
+ submitAttempts,
1240
+ symbol,
1241
+ });
1242
+ }
1243
+ const existingReceipt = await readExternalMutationReceipt(input, identity);
1244
+ if (existingReceipt) {
1245
+ return withExternalMutationRecovery(existingReceipt, async () => {
1246
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
1247
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
1248
+ if (positionMode !== 'new') {
1249
+ throw new Error('resumed funded position modification requires manual visible inspection');
1250
+ }
1251
+ let cleanupReceipt = existingReceipt;
1252
+ if (existingReceipt.record?.state === 'outcome-unknown' && orderType === 'limit') {
1253
+ const cleanupCandidate = await observePolicyViolationCleanupCandidate(
1254
+ page,
1255
+ expectedVisibleIdentity,
1256
+ existingReceipt.record,
1257
+ Number(input.node?.timeout_ms ?? 45000),
1258
+ );
1259
+ if (cleanupCandidate) {
1260
+ cleanupReceipt = await recordExternalMutationCleanupCandidate(input, existingReceipt, cleanupCandidate);
1261
+ }
1262
+ }
1263
+ if (cleanupReceipt.record?.state === 'observed-policy-violation') {
1264
+ return {
1265
+ action: input.action,
1266
+ market: symbol,
1267
+ side,
1268
+ orderType,
1269
+ positionMode,
1270
+ amount,
1271
+ leverage,
1272
+ limitPrice: stableLimitPrice,
1273
+ submitted: false,
1274
+ submitAttempts,
1275
+ receipt: {
1276
+ path: cleanupReceipt.relativePath,
1277
+ sha256: cleanupReceipt.sha256,
1278
+ state: cleanupReceipt.record.state,
1279
+ },
1280
+ resumed: true,
1281
+ policyViolation: true,
1282
+ cleanupCandidate: cleanupReceipt.record.cleanupCandidate,
1283
+ proofPath: 'visible-product-controls',
1284
+ };
1285
+ }
1286
+ const mutation = await runGuardedExternalMutation({
1287
+ input,
1288
+ identity,
1289
+ dispatch: async () => {
1290
+ throw new Error('resumed order must not dispatch');
1291
+ },
1292
+ observe: async ({ receipt }) => observeExactVisibleOrderResult(
1293
+ page,
1294
+ expectedVisibleIdentity,
1295
+ receipt,
1296
+ Number(input.node?.timeout_ms ?? 45000),
1297
+ ),
1298
+ });
1299
+ return {
1300
+ action: input.action,
1301
+ market: symbol,
1302
+ side,
1303
+ orderType,
1304
+ positionMode,
1305
+ amount,
1306
+ leverage,
1307
+ limitPrice: stableLimitPrice,
1308
+ submitted: true,
1309
+ submitAttempts,
1310
+ receipt: mutation.receipt,
1311
+ resumed: true,
1312
+ recovery: mutation.recovery,
1313
+ initialOpenOrderCount: null,
1314
+ initialGlobalPositionCount: null,
1315
+ initialGlobalOrderCount: null,
1316
+ openOrderCount: mutation.observation.order ? 1 : null,
1317
+ configured: null,
1318
+ outcome: mutation.observation,
1319
+ proofPath: 'visible-product-controls',
1320
+ };
1321
+ });
1322
+ }
1323
+ await page.navigateHash('#/perps-home');
1324
+ const initialClean = await waitForStableGlobalPerpsHome(page, {
1325
+ timeoutMs: Number(input.node?.timeout_ms ?? 45_000),
1326
+ requireZero: true,
1327
+ });
1328
+ const fundingBalance = await requireCurrentVisibleFundingBalance(
1329
+ input,
1330
+ page,
1331
+ Number(input.node?.timeout_ms ?? 45_000),
1332
+ initialClean.balanceBaseUnits,
1333
+ );
1334
+ const tradingContext = await requirePerpsTradingContext(input, page);
1335
+ const balanceBeforeBaseUnits = fundingBalance.observedBaseUnits;
520
1336
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
521
1337
  await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
522
- await page.evaluate(`(async () => {
523
- const request = globalThis.stateHooks?.submitRequestToBackground;
524
- if (typeof request !== 'function') return;
525
- const state = globalThis.stateHooks?.store?.getState?.()?.metamask;
526
- if (state && state.isTestnet === false) {
527
- await request('perpsToggleTestnet', []);
528
- await new Promise((resolve) => setTimeout(resolve, 2000));
529
- }
530
- })()`, { awaitPromise: true });
531
1338
  const resolvedPrice = await readCurrentMarketPrice(
532
1339
  page,
533
1340
  symbol,
@@ -536,21 +1343,26 @@ export async function placeOrder(input) {
536
1343
  const limitPrice = orderType === 'limit'
537
1344
  ? resolveLimitPrice(input, isBuy, resolvedPrice)
538
1345
  : null;
539
- const initialPositionSize = positionMode === 'modify'
540
- ? await page.evaluate(`document.querySelector('[data-testid="perps-position-size-value"]')?.textContent?.trim() ?? null`)
541
- : null;
542
- const initialOpenOrderCount = orderType === 'limit'
543
- ? await page.evaluate(`document.querySelectorAll('[data-testid^="order-card-"]').length`)
544
- : null;
1346
+ const baselineOrderIds = orderType === 'limit'
1347
+ ? (await readVisibleOrderCards(page, symbol)).map((order) => order.id)
1348
+ : [];
1349
+ const initialOpenOrderCount = baselineOrderIds.length;
545
1350
  await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=${positionMode}`);
546
1351
  await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
547
- const hasBackgroundRequest = await page.evaluate(
1352
+ const lateBackgroundRequest = await page.evaluate(
548
1353
  `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
549
1354
  );
550
- if (!hasBackgroundRequest) {
1355
+ if (lateBackgroundRequest) {
1356
+ throw new Error('Background Perps placement appeared after visible setup; refusing an ambiguous dispatch.');
1357
+ }
1358
+ if (!lateBackgroundRequest) {
551
1359
  if (input.node?.confirm_live !== true) {
552
1360
  throw new Error('metamask.perps.place_order requires confirm_live=true for visible live submission.');
553
1361
  }
1362
+ let configured = null;
1363
+ if (submitAttempts !== 1) {
1364
+ throw new Error('Visible funded Perps submission requires submit_attempts=1.');
1365
+ }
554
1366
  await page.setInput(`${dataTestId('amount-input-field')} input`, amount);
555
1367
  await page.setInput(`${dataTestId('leverage-input')} input`, String(leverage));
556
1368
  if (orderType === 'limit') {
@@ -560,103 +1372,86 @@ export async function placeOrder(input) {
560
1372
  await page.setInput(limitInput, String(limitPrice));
561
1373
  }
562
1374
  const configuredDeadline = Date.now() + 10_000;
563
- let configured;
564
1375
  while (Date.now() < configuredDeadline) {
565
- configured = await page.evaluate(`(() => {
566
- const value = (testId) => document.querySelector('[data-testid="' + testId + '"] input')?.value ?? null;
567
- const limit = document.querySelector('[data-testid="limit-price-input"] input')?.value ?? null;
568
- const submit = document.querySelector('[data-testid="submit-order-button"]');
569
- return {
570
- amount: value('amount-input-field'),
571
- leverage: value('leverage-input'),
572
- limitPrice: limit,
573
- limitSelected: document.querySelector('[data-testid="order-type-limit"]')?.getAttribute('aria-pressed') === 'true',
574
- submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
575
- validation: [...document.querySelectorAll('[role="alert"], [data-testid$="validation-error"], [data-testid$="warning"]')]
576
- .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
577
- .filter(Boolean)
578
- .slice(0, 20),
579
- };
580
- })()`);
581
- if (configured.submitEnabled) break;
582
- await sleep(250);
1376
+ configured = await page.evaluate(`(() => {
1377
+ const value = (testId) => document.querySelector('[data-testid="' + testId + '"] input')?.value ?? null;
1378
+ const limit = document.querySelector('[data-testid="limit-price-input"] input')?.value ?? null;
1379
+ const submit = document.querySelector('[data-testid="submit-order-button"]');
1380
+ return {
1381
+ amount: value('amount-input-field'),
1382
+ leverage: value('leverage-input'),
1383
+ limitPrice: limit,
1384
+ limitSelected: document.querySelector('[data-testid="order-type-limit"]')?.getAttribute('aria-pressed') === 'true',
1385
+ submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
1386
+ };
1387
+ })()`);
1388
+ if (configured.submitEnabled) break;
1389
+ await sleep(250);
583
1390
  }
584
1391
  if (Number(configured.amount) !== Number(amount) || Number(configured.leverage) !== leverage) {
585
- throw new Error(`Visible Perps order inputs did not retain the requested values: ${JSON.stringify(configured)}.`);
1392
+ throw new Error('Visible Perps order inputs did not retain the requested values.');
586
1393
  }
587
1394
  if (orderType === 'limit' && (!configured.limitSelected || Number(configured.limitPrice) !== limitPrice)) {
588
- throw new Error(`Visible Perps limit inputs did not retain the requested values: ${JSON.stringify(configured)}.`);
589
- }
590
- if (!configured.submitEnabled) {
591
- throw new Error(`Visible Perps order is not submittable: ${JSON.stringify(configured)}.`);
592
- }
593
- const submitTarget = await page.evaluate(`(() => {
594
- const element = document.querySelector('[data-testid="submit-order-button"]');
595
- if (!element) throw new Error('Visible Perps submit control disappeared.');
596
- element.scrollIntoView({ block: 'center', inline: 'center' });
597
- const rect = element.getBoundingClientRect();
598
- return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
599
- })()`);
600
- for (let attempt = 0; attempt < submitAttempts; attempt += 1) {
601
- await page.session.call('Input.dispatchMouseEvent', {
602
- type: 'mousePressed',
603
- x: submitTarget.x,
604
- y: submitTarget.y,
605
- button: 'left',
606
- buttons: 1,
607
- clickCount: 1,
608
- });
609
- await page.session.call('Input.dispatchMouseEvent', {
610
- type: 'mouseReleased',
611
- x: submitTarget.x,
612
- y: submitTarget.y,
613
- button: 'left',
614
- buttons: 0,
615
- clickCount: 1,
616
- });
617
- }
618
- const resultSelector = orderType === 'limit'
619
- ? '[data-testid^="order-card-"]'
620
- : dataTestId('perps-position-size-value');
621
- const resultDeadline = Date.now() + Number(input.node?.timeout_ms ?? 45000);
622
- let outcome;
623
- while (Date.now() < resultDeadline) {
624
- outcome = await page.evaluate(`(() => ({
625
- success: ${JSON.stringify(positionMode)} === 'modify'
626
- ? String(location.hash).startsWith(${JSON.stringify(`#/perps/market/${symbol}`)}) &&
627
- String(document.querySelector('[data-testid="perps-position-size-value"]')?.textContent || '').trim() !== ${JSON.stringify(initialPositionSize)}
628
- : Boolean(document.querySelector(${JSON.stringify(resultSelector)})),
629
- route: String(location.hash),
630
- submit: (() => {
631
- const element = document.querySelector('[data-testid="submit-order-button"]');
632
- return element ? {
633
- label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim(),
634
- enabled: !element.disabled && element.getAttribute('aria-disabled') !== 'true',
635
- } : null;
636
- })(),
637
- errors: [...document.querySelectorAll('[data-testid="perps-order-submit-error"], [role="alert"]')]
638
- .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
639
- .filter(Boolean)
640
- .slice(0, 20),
641
- }))()`);
642
- if (outcome.success) break;
643
- if (outcome.errors.length > 0) {
644
- throw new Error(`Visible Perps order submission failed: ${outcome.errors.join(' | ')}`);
645
- }
646
- await sleep(250);
647
- }
648
- if (!outcome?.success) {
649
- throw new Error(`Visible Perps order submission did not produce ${resultSelector}; observed ${JSON.stringify(outcome)}.`);
1395
+ throw new Error('Visible Perps limit inputs did not retain the requested values.');
650
1396
  }
1397
+ if (!configured.submitEnabled) throw new Error('Visible Perps order is not submittable.');
1398
+ const mutation = await runGuardedExternalMutation({
1399
+ input,
1400
+ identity,
1401
+ publicationFacts: { balanceBeforeBaseUnits, baselineOrderIds, fundingBalance, tradingContext },
1402
+ dispatch: async () => {
1403
+ let dispatchTarget;
1404
+ try {
1405
+ await requireCurrentOrderEntryFundingBalance(input, page, fundingBalance.observedBaseUnits);
1406
+ await requirePerpsTradingContext(input, page);
1407
+ const currentConfiguration = await page.evaluate(`(() => {
1408
+ const value = (testId) => document.querySelector('[data-testid="' + testId + '"] input')?.value ?? null;
1409
+ const limitSelected = document.querySelector('[data-testid="order-type-limit"]')?.getAttribute('aria-pressed') === 'true';
1410
+ const element = document.querySelector('[data-testid="submit-order-button"]');
1411
+ if (!element || element.disabled || element.getAttribute('aria-disabled') === 'true') {
1412
+ throw new Error('Visible Perps submit control is unavailable.');
1413
+ }
1414
+ element.scrollIntoView({ block: 'center', inline: 'center' });
1415
+ const rect = element.getBoundingClientRect();
1416
+ return {
1417
+ amount: value('amount-input-field'),
1418
+ leverage: value('leverage-input'),
1419
+ limitPrice: value('limit-price-input'),
1420
+ orderType: limitSelected ? 'limit' : 'market',
1421
+ target: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
1422
+ };
1423
+ })()`);
1424
+ if (currentConfiguration.amount !== amount
1425
+ || currentConfiguration.leverage !== String(leverage)
1426
+ || currentConfiguration.orderType !== orderType
1427
+ || (orderType === 'limit' && currentConfiguration.limitPrice !== String(limitPrice))
1428
+ || (orderType === 'market' && currentConfiguration.limitPrice != null)) {
1429
+ throw new Error('Visible Perps order inputs changed before dispatch.');
1430
+ }
1431
+ dispatchTarget = currentConfiguration.target;
1432
+ assertTrustedExternalMutationTarget(input, page, identity);
1433
+ } catch (error) {
1434
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
1435
+ }
1436
+ await page.session.call('Input.dispatchMouseEvent', {
1437
+ type: 'mousePressed', x: dispatchTarget.x, y: dispatchTarget.y,
1438
+ button: 'left', buttons: 1, clickCount: 1,
1439
+ });
1440
+ await page.session.call('Input.dispatchMouseEvent', {
1441
+ type: 'mouseReleased', x: dispatchTarget.x, y: dispatchTarget.y,
1442
+ button: 'left', buttons: 0, clickCount: 1,
1443
+ });
1444
+ },
1445
+ observe: async ({ receipt }) => observeExactVisibleOrderResult(
1446
+ page,
1447
+ expectedVisibleIdentity,
1448
+ receipt,
1449
+ Number(input.node?.timeout_ms ?? 45000),
1450
+ ),
1451
+ });
651
1452
  let openOrderCount = null;
652
1453
  if (orderType === 'limit') {
653
- await sleep(2_000);
654
- await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
655
- await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
656
- openOrderCount = await page.evaluate(`document.querySelectorAll('[data-testid^="order-card-"]').length`);
657
- if (openOrderCount !== initialOpenOrderCount + 1) {
658
- throw new Error(`Perps limit submission created an unexpected open-order count: before=${initialOpenOrderCount}, after=${openOrderCount}.`);
659
- }
1454
+ openOrderCount = mutation.observation.order ? 1 : null;
660
1455
  }
661
1456
  return {
662
1457
  action: input.action,
@@ -669,62 +1464,20 @@ export async function placeOrder(input) {
669
1464
  limitPrice,
670
1465
  submitted: true,
671
1466
  submitAttempts,
1467
+ receipt: mutation.receipt,
1468
+ resumed: mutation.resumed,
1469
+ recovery: mutation.recovery,
672
1470
  initialOpenOrderCount,
1471
+ initialGlobalPositionCount: initialClean.positionCount,
1472
+ initialGlobalOrderCount: initialClean.orderCount,
673
1473
  openOrderCount,
674
1474
  configured,
675
- outcome,
1475
+ outcome: mutation.observation,
1476
+ balanceBeforeBaseUnits,
676
1477
  proofPath: 'visible-product-controls',
677
1478
  };
678
1479
  }
679
- let order;
680
- try {
681
- order = await page.evaluate(`(async () => {
682
- const request = globalThis.stateHooks?.submitRequestToBackground;
683
- if (typeof request !== 'function') throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot place live Perps order.');
684
- const currentPrice = Number(${JSON.stringify(resolvedPrice)});
685
- if (!Number.isFinite(currentPrice) || currentPrice <= 0) throw new Error('Unable to determine current Perps price for order placement.');
686
- const usdAmount = ${JSON.stringify(amount)};
687
- const leverage = ${JSON.stringify(leverage)};
688
- const orderType = ${JSON.stringify(orderType)};
689
- const limitPrice = ${JSON.stringify(limitPrice)};
690
- const orderParams = orderType === 'limit' ? {
691
- symbol: ${JSON.stringify(symbol)},
692
- isBuy: ${JSON.stringify(isBuy)},
693
- size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
694
- orderType,
695
- price: String(limitPrice),
696
- timeInForce: 'GTC',
697
- leverage,
698
- currentPrice,
699
- priceAtCalculation: currentPrice,
700
- maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
701
- } : {
702
- symbol: ${JSON.stringify(symbol)},
703
- isBuy: ${JSON.stringify(isBuy)},
704
- size: ((Number(usdAmount) * Number(leverage)) / currentPrice).toString(),
705
- orderType,
706
- leverage,
707
- currentPrice,
708
- priceAtCalculation: currentPrice,
709
- usdAmount,
710
- maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
711
- };
712
- const result = await request('perpsPlaceOrder', [orderParams]);
713
- if (!result || result.success !== true) {
714
- throw new Error(result?.error || 'perpsPlaceOrder failed.');
715
- }
716
- return { result, orderParams };
717
- })()`, { awaitPromise: true });
718
- } catch (error) {
719
- const message = error instanceof Error ? error.message : String(error);
720
- throw new Error(`${message} (resolvedPrice=${resolvedPrice})`);
721
- }
722
- if (orderType === 'limit') {
723
- await waitForSelectedOrderState(page, input, true, Number(input.node?.timeout_ms ?? 30000));
724
- } else {
725
- await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
726
- }
727
- return { action: input.action, market: symbol, side, orderType, positionMode, amount, leverage, limitPrice, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
1480
+ throw new Error('Visible Perps placement did not return a terminal result.');
728
1481
  });
729
1482
  }
730
1483
 
@@ -733,16 +1486,9 @@ export async function ensurePositions(input) {
733
1486
  requireExplicitSelection(input);
734
1487
  const state = String(input.node.state).toLowerCase();
735
1488
  if (state === 'none' || state === 'closed' || state === 'absent') {
736
- const close = await closePositions(input);
737
- return { ...(await assertPositions(input, false)), close };
1489
+ return assertPositions(input, false);
738
1490
  }
739
1491
  if (state === 'open' || state === 'present') {
740
- const current = await withExtensionPage(input, async (page) => {
741
- const stateResult = await page.readPositions();
742
- if (!stateResult.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot ensure live Perps positions.');
743
- return positionSelectionState(input, stateResult.positions);
744
- });
745
- if (!current.hasMatching) await placeOrder(input);
746
1492
  return assertPositions(input, true);
747
1493
  }
748
1494
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
@@ -753,18 +1499,10 @@ export async function ensureOrders(input) {
753
1499
  requireExplicitSelection(input);
754
1500
  const state = String(input.node.state).toLowerCase();
755
1501
  if (state === 'none' || state === 'closed' || state === 'absent') {
756
- const close = await closeOrders(input);
757
- return { ...(await assertOrders(input, false)), close };
1502
+ return assertOrders(input, false);
758
1503
  }
759
1504
  if (state === 'open' || state === 'present') {
760
- const current = await withExtensionPage(input, async (page) => ({
761
- orders: await readOpenOrders(page),
762
- }));
763
- let order = null;
764
- if (selectedItems(input, current.orders).length === 0) {
765
- order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
766
- }
767
- return { ...(await assertOrders(input, true)), order };
1505
+ return assertOrders(input, true);
768
1506
  }
769
1507
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
770
1508
  }
@@ -794,56 +1532,6 @@ function paramsForState(input) {
794
1532
  return { ...nested, ...input.node };
795
1533
  }
796
1534
 
797
- function profileDefaults(profile) {
798
- const selected = String(profile ?? 'clean_market_testnet');
799
- if (selected === 'clean_market_testnet') {
800
- return {
801
- provider: 'hyperliquid',
802
- network: 'testnet',
803
- page: 'market',
804
- positions: { state: 'none', mode: 'matching' },
805
- orders: { state: 'none', mode: 'matching' },
806
- };
807
- }
808
- if (selected === 'open_position_testnet') {
809
- return {
810
- provider: 'hyperliquid',
811
- network: 'testnet',
812
- page: 'market',
813
- positions: { state: 'open', mode: 'matching' },
814
- orders: { state: 'none', mode: 'matching' },
815
- };
816
- }
817
- if (selected === 'open_order_testnet') {
818
- return {
819
- provider: 'hyperliquid',
820
- network: 'testnet',
821
- page: 'market',
822
- positions: false,
823
- orders: { state: 'open', mode: 'matching' },
824
- };
825
- }
826
- if (selected === 'provider_mainnet_readonly') {
827
- return {
828
- provider: 'hyperliquid',
829
- network: 'mainnet',
830
- page: 'home',
831
- positions: false,
832
- orders: false,
833
- };
834
- }
835
- if (selected === 'clean_market_mainnet') {
836
- return {
837
- provider: 'hyperliquid',
838
- network: 'mainnet',
839
- page: 'market',
840
- positions: { state: 'none', mode: 'matching' },
841
- orders: { state: 'none', mode: 'matching' },
842
- };
843
- }
844
- return {};
845
- }
846
-
847
1535
  function mergeStateConfig(defaults, params) {
848
1536
  return {
849
1537
  ...defaults,
@@ -923,28 +1611,10 @@ async function ensureNetwork(input, config) {
923
1611
  if (!before.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot enforce Perps network.');
924
1612
  if (!before.networkKnown) throw new Error('Unable to read the current Perps network from persisted controller state.');
925
1613
  if (Boolean(before.isTestnet) === expectedTestnet) return { requested: config.network, isTestnet: before.isTestnet, changed: false };
926
- // The toggle request can remain pending after the controller state changes.
927
- // Convergence is proved by the bounded state poll below, not request completion.
928
- const result = await triggerBackground(page, 'perpsToggleTestnet', []);
929
- const after = await waitForNetworkState(page, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
930
- if (Boolean(after.isTestnet) !== expectedTestnet) {
931
- throw new Error(`Expected Perps network ${config.network}, got isTestnet=${after.isTestnet}.`);
932
- }
933
- return { requested: config.network, isTestnet: after.isTestnet, changed: true, result };
1614
+ throw new Error(`Expected Perps network ${config.network}, got isTestnet=${before.isTestnet}. Automatic background network toggles are forbidden without a durable receipt; select the network through a supported visible action before running.`);
934
1615
  });
935
1616
  }
936
1617
 
937
- async function waitForNetworkState(page, expectedTestnet, timeoutMs) {
938
- const deadline = Date.now() + timeoutMs;
939
- let last = await readPerpsRuntimeState(page);
940
- while (Date.now() < deadline) {
941
- last = await readPerpsRuntimeState(page);
942
- if (Boolean(last.isTestnet) === expectedTestnet) return last;
943
- await sleep(500);
944
- }
945
- return last;
946
- }
947
-
948
1618
  async function assertReadyToTrade(input, config) {
949
1619
  if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
950
1620
  return withExtensionPage(input, async (page) => {
@@ -976,9 +1646,8 @@ async function assertBalance(input, config) {
976
1646
 
977
1647
  export async function startState(input) {
978
1648
  const params = paramsForState(input);
979
- const config = mergeStateConfig(profileDefaults(params.profile), params);
980
- // Enter the Perps surface first so its stream bridge/controller API exists
981
- // before provider, network, order, or position convergence begins.
1649
+ const config = mergeStateConfig({}, params);
1650
+ // Enter the Perps surface first so its stream bridge/controller API exists.
982
1651
  const navigation = await applyStateNavigation(input, config);
983
1652
  const provider = await ensureProvider(input, config);
984
1653
  const network = await ensureNetwork(input, config);
@@ -988,7 +1657,6 @@ export async function startState(input) {
988
1657
  const positions = await applyPositionsState(input, config.positions);
989
1658
  return {
990
1659
  action: input.action,
991
- profile: config.profile ?? params.profile ?? 'clean_market_testnet',
992
1660
  phase: 'start_state',
993
1661
  provider,
994
1662
  network,
@@ -1028,12 +1696,10 @@ const DIRECT_ACTIONS = new Map([
1028
1696
  ['metamask.perps.read_snapshot', readSnapshot],
1029
1697
  ['metamask.perps.read_positions', readPositions],
1030
1698
  ['metamask.perps.read_orders', readOrders],
1031
- ['metamask.perps.close_positions', closePositions],
1032
1699
  ['metamask.perps.close_orders', closeOrders],
1033
1700
  ['metamask.perps.place_order', placeOrder],
1034
1701
  ['metamask.perps.ensure_positions', ensurePositions],
1035
1702
  ['metamask.perps.ensure_orders', ensureOrders],
1036
- ['metamask.perps.start_state', startState],
1037
1703
  ['metamask.perps.teardown_state', teardownState],
1038
1704
  ]);
1039
1705