@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,3 +1,5 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
1
3
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
4
  import {
3
5
  normalizeVisiblePerpsState,
@@ -16,7 +18,23 @@ const FILTERS = new Map([
16
18
  ['new', 'new'],
17
19
  ]);
18
20
 
19
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
21
+ async function visibleLabel(page, selector) {
22
+ return page.evaluate(`(() => {
23
+ const element = document.querySelector(${JSON.stringify(selector)});
24
+ if (!element) return { visible: false, label: '' };
25
+ const style = getComputedStyle(element);
26
+ const rect = element.getBoundingClientRect();
27
+ return {
28
+ visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0,
29
+ label: String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
30
+ .replace(/\\s+/gu, ' ')
31
+ .trim()
32
+ .slice(0, 500),
33
+ };
34
+ })()`);
35
+ }
36
+
37
+ export async function selectMarketFilter(page, input, pollOptions = {}) {
20
38
  const filter = String(input.node?.filter ?? '').trim().toLowerCase();
21
39
  const option = FILTERS.get(filter);
22
40
  if (!option) {
@@ -29,15 +47,35 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
29
47
  minimum_market_count: input.node?.minimum_market_count ?? 1,
30
48
  maximum_market_count: input.node?.maximum_market_count ?? 200,
31
49
  });
50
+ const timeoutMs = pollOptions.timeoutMs ?? input.node?.timeout_ms ?? 10_000;
51
+ const pollIntervalMs = pollOptions.pollIntervalMs ?? 100;
32
52
  await page.click(dataTestId('filter-select-button'));
33
53
  const optionSelector = dataTestId(`filter-select-option-${option}`);
34
54
  try {
35
- await page.waitForSelector(optionSelector, { timeoutMs: 2_000 });
55
+ await page.waitForSelector(optionSelector, { timeoutMs: Math.min(timeoutMs, 2_000) });
36
56
  } catch {
37
57
  throw new Error(`Perps market filter ${JSON.stringify(filter)} is not available in this release.`);
38
58
  }
59
+ const optionState = await visibleLabel(page, optionSelector);
60
+ if (!optionState.visible || !optionState.label) {
61
+ throw new Error(`Perps market filter ${JSON.stringify(filter)} is not visibly available in this release.`);
62
+ }
39
63
  await page.click(optionSelector);
40
- await new Promise((resolve) => setTimeout(resolve, 300));
64
+ const selectedSelector = dataTestId('filter-select-button');
65
+ const deadline = Date.now() + timeoutMs;
66
+ let selectedState;
67
+ while (true) {
68
+ selectedState = await visibleLabel(page, selectedSelector);
69
+ if (selectedState.visible && selectedState.label === optionState.label) break;
70
+ if (Date.now() >= deadline) {
71
+ throw new Error(
72
+ `Perps market filter ${JSON.stringify(filter)} did not select within ${timeoutMs}ms: ` +
73
+ `expected label ${JSON.stringify(optionState.label)}, observed ${JSON.stringify(selectedState.label)} ` +
74
+ `(visible=${selectedState.visible === true}).`,
75
+ );
76
+ }
77
+ await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${JSON.stringify(pollIntervalMs)}))`);
78
+ }
41
79
  const raw = await page.evaluate(`(() => {
42
80
  const maximum = ${JSON.stringify(options.maxItems)};
43
81
  const items = [];
@@ -66,7 +104,13 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
66
104
  return {
67
105
  action: input.action,
68
106
  filter,
107
+ option,
108
+ selectedLabel: selectedState.label,
69
109
  ...normalizeVisiblePerpsState(raw, options, 'extension'),
70
110
  proofPath: 'trusted-pointer-visible-ui',
71
111
  };
72
- }));
112
+ }
113
+
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
115
+ runAdapter((input) => withExtensionPage(input, (page) => selectMarketFilter(page, input)));
116
+ }
@@ -9,6 +9,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
9
9
  throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
10
10
  }
11
11
  const desired = input.node.favorite;
12
+ const requireUnchanged = input.node?.require_unchanged === true;
12
13
  const selector = dataTestId('perps-market-detail-favorite-button');
13
14
  const readState = () => page.evaluate(`(() => {
14
15
  const market = ${JSON.stringify(market)};
@@ -35,11 +36,15 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
35
36
  if (after.favorite !== desired) {
36
37
  throw new Error(`Perps favorite state did not become ${desired}.`);
37
38
  }
39
+ const changed = before.favorite !== after.favorite;
40
+ if (requireUnchanged && changed) {
41
+ throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
42
+ }
38
43
  return {
39
44
  action: input.action,
40
45
  market,
41
46
  requestedFavorite: desired,
42
- changed: before.favorite !== after.favorite,
47
+ changed,
43
48
  before,
44
49
  after,
45
50
  proofPath: 'trusted-pointer-visible-ui',
@@ -1,49 +1,131 @@
1
- import { dataTestId, marketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ assertTrustedExternalMutationTarget,
4
+ externalMutationNotDispatched,
5
+ readExternalMutationReceipt,
6
+ runGuardedExternalMutation,
7
+ trustedExternalMutationIdentity,
8
+ withExternalMutationRecovery,
9
+ } from './mutation-receipt.mjs';
10
+ import { requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
11
+ import {
12
+ normalizePositionSizeIdentity,
13
+ parsePositionDetail,
14
+ parseUsdBaseUnits,
15
+ positionIdentityMatches,
16
+ } from './visible-mutation-identity.mjs';
2
17
 
3
- function displayedPrice(value) {
4
- const match = String(value).replace(/,/gu, '').match(/-?\d+(?:\.\d+)?/u);
5
- return match ? Number(match[0]) : null;
18
+ function marketSymbol(value) {
19
+ const market = String(value ?? '').trim().toUpperCase();
20
+ if (!/^[A-Z0-9:_-]{2,20}$/u.test(market)) {
21
+ throw new Error('metamask.perps.update_position_tpsl requires market=<symbol>.');
22
+ }
23
+ return market;
6
24
  }
7
25
 
8
- function matchesRequestedPrice(displayed, requested) {
9
- const observed = displayedPrice(displayed);
10
- const expected = Number(requested);
11
- return observed !== null && Number.isFinite(observed)
12
- && Math.abs(observed - expected) <= Math.max(0.005, Math.abs(expected) * 1e-10);
26
+ function price(value, label) {
27
+ const text = String(value ?? '');
28
+ if (!/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(text)) {
29
+ throw new Error(`metamask.perps.update_position_tpsl requires a positive ${label}.`);
30
+ }
31
+ return text;
13
32
  }
14
33
 
15
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
16
- const market = marketSymbol(input);
17
- const takeProfit = input.node?.take_profit_price;
18
- const stopLoss = input.node?.stop_loss_price;
19
- if (!market) throw new Error('metamask.perps.update_position_tpsl requires market=<symbol>.');
20
- if (takeProfit == null && stopLoss == null) {
21
- throw new Error('metamask.perps.update_position_tpsl requires take_profit_price and/or stop_loss_price.');
34
+ async function readPosition(page, market) {
35
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
36
+ await page.waitForSelector(dataTestId('perps-position-size-value'), { timeoutMs: 20_000 });
37
+ const raw = await page.evaluate(`(() => {
38
+ const read = (testId) => String(document.querySelector('[data-testid="' + testId + '"]')?.innerText || '')
39
+ .replace(/\\s+/gu, ' ')
40
+ .trim()
41
+ .slice(0, 500);
42
+ return {
43
+ route: String(location.hash),
44
+ pair: read('perps-market-detail-pair'),
45
+ direction: read('perps-position-leverage'),
46
+ size: read('perps-position-size-value'),
47
+ margin: read('perps-position-margin-value'),
48
+ liquidation: read('perps-position-liquidation-value'),
49
+ takeProfit: read('perps-auto-close-tp-value'),
50
+ stopLoss: read('perps-auto-close-sl-value'),
51
+ };
52
+ })()`);
53
+ const parsed = parsePositionDetail(raw);
54
+ if (!parsed || !raw.route.includes('/perps/market/') || !raw.pair.toUpperCase().startsWith(`${market}-`)) {
55
+ throw new Error('Visible Perps position identity is unavailable or does not match the requested market.');
22
56
  }
23
- for (const [name, value] of [['take_profit_price', takeProfit], ['stop_loss_price', stopLoss]]) {
24
- if (value != null && (!Number.isFinite(Number(value)) || Number(value) <= 0)) {
25
- throw new Error(`metamask.perps.update_position_tpsl received invalid ${name}.`);
57
+ return { ...raw, ...parsed };
58
+ }
59
+
60
+ function exactPrices(state, takeProfit, stopLoss) {
61
+ return parseUsdBaseUnits(state.takeProfit) === parseUsdBaseUnits(takeProfit)
62
+ && parseUsdBaseUnits(state.stopLoss) === parseUsdBaseUnits(stopLoss);
63
+ }
64
+
65
+ async function waitForExactPrices(page, identity, facts, timeoutMs) {
66
+ const deadline = Date.now() + timeoutMs;
67
+ let stable = null;
68
+ let stableSince = 0;
69
+ let after;
70
+ while (Date.now() <= deadline) {
71
+ after = await readPosition(page, identity.market);
72
+ const exact = exactPrices(after, facts.takeProfitPrice, facts.stopLossPrice)
73
+ && after.size === facts.positionBefore.size
74
+ && after.direction === facts.positionBefore.direction
75
+ && positionIdentityMatches(after, {
76
+ market: identity.market,
77
+ side: identity.side,
78
+ leverage: identity.leverage,
79
+ matchMargin: false,
80
+ });
81
+ if (exact) {
82
+ const key = JSON.stringify(after);
83
+ if (key !== stable) {
84
+ stable = key;
85
+ stableSince = Date.now();
86
+ } else if (Date.now() - stableSince >= 1_000) return after;
87
+ } else {
88
+ stable = null;
26
89
  }
90
+ await new Promise((resolve) => setTimeout(resolve, 250));
27
91
  }
28
- if (input.node?.confirm_live !== true) {
29
- throw new Error('metamask.perps.update_position_tpsl requires confirm_live=true for live submission.');
30
- }
92
+ throw new Error(`exact TP/SL postcondition was not visible: ${JSON.stringify(after)}.`);
93
+ }
31
94
 
32
- await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
33
- await page.waitForSelector(dataTestId('perps-auto-close-row'), { timeoutMs: 20_000 });
34
- const before = await page.evaluate(`(() => ({
35
- takeProfit: String(document.querySelector('[data-testid="perps-auto-close-tp-value"]')?.textContent || '').trim(),
36
- stopLoss: String(document.querySelector('[data-testid="perps-auto-close-sl-value"]')?.textContent || '').trim(),
37
- }))()`);
95
+ async function observePersistedPrices(page, identity, facts, timeoutMs) {
96
+ const immediate = await waitForExactPrices(page, identity, facts, timeoutMs);
97
+ await page.session.call('Page.reload', { ignoreCache: true });
98
+ await new Promise((resolve) => setTimeout(resolve, 500));
99
+ await page.waitForSelector(dataTestId('perps-position-size-value'), { timeoutMs: 20_000 });
100
+ const persisted = await waitForExactPrices(page, identity, facts, timeoutMs);
101
+ return {
102
+ success: true,
103
+ positionBefore: facts.positionBefore,
104
+ prefill: facts.prefill,
105
+ request: {
106
+ takeProfitPrice: facts.takeProfitPrice,
107
+ stopLossPrice: facts.stopLossPrice,
108
+ },
109
+ immediate,
110
+ persistedAfterReload: persisted,
111
+ };
112
+ }
113
+
114
+ async function prepareTpslModal(page, before, takeProfit, stopLoss) {
38
115
  await page.click(dataTestId('perps-auto-close-row'));
39
116
  await page.waitForSelector(dataTestId('perps-update-tpsl-modal'), { timeoutMs: 10_000 });
40
- if (takeProfit != null) {
41
- await page.setInput('input[data-testid="perps-update-tpsl-tp-price-input"], [data-testid="perps-update-tpsl-tp-price-input"] input', String(takeProfit));
42
- }
43
- if (stopLoss != null) {
44
- await page.setInput('input[data-testid="perps-update-tpsl-sl-price-input"], [data-testid="perps-update-tpsl-sl-price-input"] input', String(stopLoss));
117
+ const prefill = await page.evaluate(`(() => ({
118
+ takeProfit: document.querySelector('input[data-testid="perps-update-tpsl-tp-price-input"], [data-testid="perps-update-tpsl-tp-price-input"] input')?.value ?? null,
119
+ stopLoss: document.querySelector('input[data-testid="perps-update-tpsl-sl-price-input"], [data-testid="perps-update-tpsl-sl-price-input"] input')?.value ?? null,
120
+ }))()`);
121
+ const beforeTakeProfit = parseUsdBaseUnits(before.takeProfit);
122
+ const beforeStopLoss = parseUsdBaseUnits(before.stopLoss);
123
+ if ((beforeTakeProfit != null && parseUsdBaseUnits(prefill.takeProfit) !== beforeTakeProfit)
124
+ || (beforeStopLoss != null && parseUsdBaseUnits(prefill.stopLoss) !== beforeStopLoss)) {
125
+ throw new Error('TP/SL modal did not prefill the existing visible values.');
45
126
  }
46
-
127
+ await page.setInput('input[data-testid="perps-update-tpsl-tp-price-input"], [data-testid="perps-update-tpsl-tp-price-input"] input', takeProfit);
128
+ await page.setInput('input[data-testid="perps-update-tpsl-sl-price-input"], [data-testid="perps-update-tpsl-sl-price-input"] input', stopLoss);
47
129
  const readyDeadline = Date.now() + 10_000;
48
130
  let ready;
49
131
  while (Date.now() <= readyDeadline) {
@@ -54,53 +136,153 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
54
136
  takeProfit: value('perps-update-tpsl-tp-price-input'),
55
137
  stopLoss: value('perps-update-tpsl-sl-price-input'),
56
138
  submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
57
- entry: String(document.querySelector('[data-testid="perps-update-tpsl-entry-price-value"]')?.textContent || '').trim(),
58
- current: String(document.querySelector('[data-testid="perps-update-tpsl-current-price-value"]')?.textContent || '').trim(),
59
- liquidation: String(document.querySelector('[data-testid="perps-update-tpsl-liquidation-price-value"]')?.textContent || '').trim(),
60
- errors: [...document.querySelectorAll('[role="alert"]')]
61
- .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
62
- .filter(Boolean)
63
- .slice(0, 20),
64
139
  };
65
140
  })()`);
66
- const retained = (takeProfit == null || Number(ready.takeProfit) === Number(takeProfit)) &&
67
- (stopLoss == null || Number(ready.stopLoss) === Number(stopLoss));
68
- if (retained && ready.submitEnabled) break;
141
+ if (Number(ready.takeProfit) === Number(takeProfit)
142
+ && Number(ready.stopLoss) === Number(stopLoss) && ready.submitEnabled) break;
69
143
  await new Promise((resolve) => setTimeout(resolve, 100));
70
144
  }
71
- if (!ready?.submitEnabled || ready.errors.length > 0) {
145
+ if (!ready?.submitEnabled || Number(ready.takeProfit) !== Number(takeProfit)
146
+ || Number(ready.stopLoss) !== Number(stopLoss)) {
72
147
  throw new Error(`Perps TP/SL update is not ready: ${JSON.stringify(ready)}.`);
73
148
  }
74
- await page.click(dataTestId('perps-update-tpsl-modal-submit'));
149
+ return { prefill, ready };
150
+ }
75
151
 
76
- const resultDeadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
77
- const reconciled = (state) =>
78
- (takeProfit == null || matchesRequestedPrice(state.takeProfit, takeProfit)) &&
79
- (stopLoss == null || matchesRequestedPrice(state.stopLoss, stopLoss));
80
- let after;
81
- while (Date.now() <= resultDeadline) {
82
- after = await page.evaluate(`(() => ({
83
- modalVisible: Boolean(document.querySelector('[data-testid="perps-update-tpsl-modal"]')),
84
- takeProfit: String(document.querySelector('[data-testid="perps-auto-close-tp-value"]')?.textContent || '').trim(),
85
- stopLoss: String(document.querySelector('[data-testid="perps-auto-close-sl-value"]')?.textContent || '').trim(),
86
- errors: [...document.querySelectorAll('[role="alert"]')]
87
- .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim())
88
- .filter(Boolean)
89
- .slice(0, 20),
90
- }))()`);
91
- if (!after.modalVisible && reconciled(after)) break;
92
- if (after.errors.length > 0) throw new Error(`Perps TP/SL update failed: ${after.errors.join(' | ')}`);
93
- await new Promise((resolve) => setTimeout(resolve, 200));
152
+ export async function updatePositionTpsl(input) {
153
+ return withExtensionPage(input, async (page) => {
154
+ const market = marketSymbol(input.node?.market);
155
+ const takeProfit = price(input.node?.take_profit_price, 'take_profit_price');
156
+ const stopLoss = price(input.node?.stop_loss_price, 'stop_loss_price');
157
+ const fundingSide = String(input.node?.funding_side ?? '').trim().toLowerCase();
158
+ const fundingAmount = String(input.node?.funding_amount ?? '');
159
+ const fundingAmountBaseUnits = parseUsdBaseUnits(fundingAmount);
160
+ const fundingPositionSize = normalizePositionSizeIdentity(input.node?.funding_position_size);
161
+ const leverage = Number(input.node?.leverage);
162
+ if (!/^(?:long|short)$/u.test(fundingSide)
163
+ || !/^[1-9][0-9]{0,29}$/u.test(String(fundingAmountBaseUnits ?? ''))
164
+ || fundingPositionSize == null
165
+ || !Number.isInteger(leverage) || leverage < 1 || leverage > 100) {
166
+ throw new Error('metamask.perps.update_position_tpsl received invalid funded position inputs.');
94
167
  }
95
- if (!after || after.modalVisible || !reconciled(after)) {
96
- throw new Error(`Perps TP/SL did not reconcile after submission: ${JSON.stringify({ before, after })}.`);
168
+ if (input.node?.confirm_live !== true) {
169
+ throw new Error('metamask.perps.update_position_tpsl requires confirm_live=true for live submission.');
97
170
  }
171
+ const identity = trustedExternalMutationIdentity(input, page, {
172
+ operation: 'perps-tpsl-update',
173
+ provider: input.node?.provider,
174
+ network: input.node?.network,
175
+ market,
176
+ side: fundingSide,
177
+ amount: fundingAmount,
178
+ leverage,
179
+ orderType: 'tpsl',
180
+ positionMode: 'modify',
181
+ takeProfitPrice: takeProfit,
182
+ stopLossPrice: stopLoss,
183
+ positionAmountBaseUnits: fundingAmountBaseUnits,
184
+ positionSize: fundingPositionSize,
185
+ roundingToleranceBaseUnits: '0',
186
+ });
187
+ const existingReceipt = await readExternalMutationReceipt(input, identity);
188
+ if (existingReceipt) {
189
+ return withExternalMutationRecovery(existingReceipt, async () => {
190
+ const mutation = await runGuardedExternalMutation({
191
+ input,
192
+ identity,
193
+ dispatch: async () => { throw new Error('resumed TP/SL mutation must not dispatch'); },
194
+ observe: async ({ receipt }) => observePersistedPrices(
195
+ page,
196
+ identity,
197
+ receipt.facts,
198
+ Number(input.node?.timeout_ms ?? 45_000),
199
+ ),
200
+ });
201
+ return {
202
+ action: input.action,
203
+ market,
204
+ receipt: mutation.receipt,
205
+ resumed: true,
206
+ outcome: mutation.observation,
207
+ proofPath: 'trusted-pointer-visible-ui',
208
+ };
209
+ });
210
+ }
211
+
212
+ const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
213
+ const fundingBalance = await requireCurrentVisibleFundingBalance(input, page, timeoutMs);
214
+ const tradingContext = await requirePerpsTradingContext(input, page);
215
+ const before = await readPosition(page, market);
216
+ if (!positionIdentityMatches(before, { market, side: fundingSide, leverage, matchMargin: false })
217
+ || normalizePositionSizeIdentity(before.size) !== fundingPositionSize) {
218
+ throw new Error('Visible Perps position does not match the funded market/side/leverage/size identity.');
219
+ }
220
+ const { prefill, ready } = await prepareTpslModal(page, before, takeProfit, stopLoss);
221
+ const facts = {
222
+ positionBefore: before,
223
+ prefill,
224
+ takeProfitPrice: takeProfit,
225
+ stopLossPrice: stopLoss,
226
+ fundingBalance,
227
+ tradingContext,
228
+ };
229
+ const mutation = await runGuardedExternalMutation({
230
+ input,
231
+ identity,
232
+ publicationFacts: facts,
233
+ dispatch: async () => {
234
+ let dispatchTarget;
235
+ try {
236
+ await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
237
+ await requirePerpsTradingContext(input, page);
238
+ assertTrustedExternalMutationTarget(input, page, identity);
239
+ const current = await readPosition(page, market);
240
+ if (current.size !== before.size || current.direction !== before.direction
241
+ || normalizePositionSizeIdentity(current.size) !== fundingPositionSize
242
+ || !positionIdentityMatches(current, { market, side: fundingSide, leverage, matchMargin: false })) {
243
+ throw new Error('Visible Perps position changed before TP/SL dispatch.');
244
+ }
245
+ await prepareTpslModal(page, current, takeProfit, stopLoss);
246
+ dispatchTarget = await page.evaluate(`(() => {
247
+ const element = document.querySelector('[data-testid="perps-update-tpsl-modal-submit"]');
248
+ if (!element || element.disabled || element.getAttribute('aria-disabled') === 'true') {
249
+ throw new Error('Visible Perps TP/SL submit control is unavailable.');
250
+ }
251
+ const rect = element.getBoundingClientRect();
252
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
253
+ })()`);
254
+ assertTrustedExternalMutationTarget(input, page, identity);
255
+ } catch (error) {
256
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
257
+ }
258
+ await page.session.call('Input.dispatchMouseEvent', {
259
+ type: 'mousePressed', x: dispatchTarget.x, y: dispatchTarget.y,
260
+ button: 'left', buttons: 1, clickCount: 1,
261
+ });
262
+ await page.session.call('Input.dispatchMouseEvent', {
263
+ type: 'mouseReleased', x: dispatchTarget.x, y: dispatchTarget.y,
264
+ button: 'left', buttons: 0, clickCount: 1,
265
+ });
266
+ },
267
+ observe: async ({ receipt }) => observePersistedPrices(
268
+ page,
269
+ identity,
270
+ receipt.facts,
271
+ Number(input.node?.timeout_ms ?? 45_000),
272
+ ),
273
+ });
98
274
  return {
99
275
  action: input.action,
100
276
  market,
101
277
  before,
278
+ prefill,
102
279
  ready,
103
- after,
104
- proofPath: 'visible-product-controls',
280
+ receipt: mutation.receipt,
281
+ resumed: mutation.resumed,
282
+ outcome: mutation.observation,
283
+ proofPath: 'trusted-pointer-visible-ui',
105
284
  };
106
- }));
285
+ });
286
+ }
287
+
288
+ runAdapter(updatePositionTpsl);
@@ -0,0 +1,164 @@
1
+ const INTEGER = /^(0|[1-9][0-9]{0,29})$/u;
2
+
3
+ export function parseUsdBaseUnits(value) {
4
+ const normalized = String(value ?? '').replace(/[,$\s]/gu, '');
5
+ const match = /^([+-]?)(\d+)(?:\.(\d{1,6}))?$/u.exec(normalized);
6
+ if (!match) return null;
7
+ const magnitude = BigInt(`${match[2]}${String(match[3] ?? '').padEnd(6, '0')}`);
8
+ return `${match[1] === '-' ? -magnitude : magnitude}`;
9
+ }
10
+
11
+ export function parsePositiveBaseUnits(value, label = 'base units') {
12
+ const text = String(value ?? '');
13
+ if (!INTEGER.test(text) || text === '0') throw new Error(`${label} must be a positive integer string.`);
14
+ return text;
15
+ }
16
+
17
+ export function absoluteDelta(left, right) {
18
+ const delta = BigInt(left) - BigInt(right);
19
+ return `${delta < 0n ? -delta : delta}`;
20
+ }
21
+
22
+ export function withinBaseUnitTolerance(actual, expected, tolerance) {
23
+ if (![actual, expected, tolerance].every((value) => /^-?\d+$/u.test(String(value)))) return false;
24
+ return BigInt(absoluteDelta(actual, expected)) <= BigInt(tolerance);
25
+ }
26
+
27
+ export function normalizedNumericInputMatches(observed, requested) {
28
+ const value = String(observed ?? '');
29
+ const expected = String(requested ?? '');
30
+ return /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/u.test(value)
31
+ && /^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(expected)
32
+ && Number(value) === Number(expected);
33
+ }
34
+
35
+ export function normalizePositionSizeIdentity(value) {
36
+ const match = /^([0-9][0-9,]*)(?:\.([0-9]+))?\s+([A-Z0-9:_-]{2,20})$/u.exec(
37
+ String(value ?? '').trim().toUpperCase(),
38
+ );
39
+ if (!match) return null;
40
+ const whole = match[1].replace(/,/gu, '').replace(/^0+(?=\d)/u, '');
41
+ const fraction = String(match[2] ?? '').replace(/0+$/u, '');
42
+ if (!/[1-9]/u.test(`${whole}${fraction}`)) return null;
43
+ return `${whole}${fraction ? `.${fraction}` : ''} ${match[3]}`;
44
+ }
45
+
46
+ export function positionIdentityMatches(observed, expected) {
47
+ const identityMatches = Boolean(observed)
48
+ && String(observed.market).toUpperCase() === String(expected.market).toUpperCase()
49
+ && String(observed.side).toLowerCase() === String(expected.side).toLowerCase()
50
+ && Number(observed.leverage) === Number(expected.leverage);
51
+ if (!identityMatches) return false;
52
+ if (expected.matchMargin === false) {
53
+ const size = /^([0-9][0-9,]*)(?:\.([0-9]+))?(?:\s|$)/u.exec(String(observed.size ?? '').trim());
54
+ return /^\d+$/u.test(String(observed.marginBaseUnits))
55
+ && BigInt(observed.marginBaseUnits) > 0n
56
+ && Boolean(size && /[1-9]/u.test(`${size[1]}${size[2] ?? ''}`));
57
+ }
58
+ return withinBaseUnitTolerance(
59
+ observed.marginBaseUnits,
60
+ expected.amountBaseUnits,
61
+ expected.roundingToleranceBaseUnits,
62
+ );
63
+ }
64
+
65
+ export function orderIdentityMatches(observed, expected) {
66
+ return Boolean(observed)
67
+ && String(observed.market).toUpperCase() === String(expected.market).toUpperCase()
68
+ && String(observed.side).toLowerCase() === String(expected.side).toLowerCase()
69
+ && String(observed.orderType).toLowerCase() === String(expected.orderType).toLowerCase()
70
+ && withinBaseUnitTolerance(
71
+ observed.notionalBaseUnits,
72
+ expected.notionalBaseUnits,
73
+ expected.roundingToleranceBaseUnits,
74
+ )
75
+ && withinBaseUnitTolerance(
76
+ observed.limitPriceBaseUnits,
77
+ expected.limitPriceBaseUnits,
78
+ expected.roundingToleranceBaseUnits,
79
+ );
80
+ }
81
+
82
+ export function parsePositionDetail(raw) {
83
+ const pair = String(raw?.pair ?? '').trim().toUpperCase();
84
+ const direction = /\b(long|short)\s+(\d+(?:\.\d+)?)x\b/iu.exec(String(raw?.direction ?? ''));
85
+ const marginBaseUnits = parseUsdBaseUnits(raw?.margin);
86
+ if (!pair || !direction || marginBaseUnits == null) return null;
87
+ return {
88
+ market: pair.split(/[-/\s]/u)[0],
89
+ side: direction[1].toLowerCase(),
90
+ leverage: Number(direction[2]),
91
+ marginBaseUnits,
92
+ size: String(raw?.size ?? '').trim().slice(0, 128),
93
+ };
94
+ }
95
+
96
+ export function parseOrderCard(raw, market) {
97
+ const text = String(raw?.text ?? '').replace(/\s+/gu, ' ').trim();
98
+ const label = /\b(market|limit)\s+(long|short)\b/iu.exec(text);
99
+ const price = [...text.matchAll(/\$\s*([0-9][0-9,]*(?:\.[0-9]{1,6})?)/gu)].at(-1)?.[0];
100
+ const size = /([0-9]+(?:\.[0-9]+)?)\s+([A-Z0-9:_-]{2,20})\b/u.exec(text);
101
+ const limitPriceBaseUnits = parseUsdBaseUnits(price);
102
+ if (!label || !size || limitPriceBaseUnits == null) return null;
103
+ const notional = Number(size[1]) * Number(limitPriceBaseUnits) / 1_000_000;
104
+ if (!Number.isFinite(notional) || notional <= 0) return null;
105
+ return {
106
+ id: String(raw?.id ?? ''),
107
+ market: String(market ?? size[2]).toUpperCase(),
108
+ side: label[2].toLowerCase(),
109
+ orderType: label[1].toLowerCase(),
110
+ size: size[1],
111
+ limitPriceBaseUnits,
112
+ notionalBaseUnits: `${Math.round(notional * 1_000_000)}`,
113
+ };
114
+ }
115
+
116
+ export async function waitForStableGlobalPerpsHome(page, options = {}) {
117
+ const timeoutMs = Number(options.timeoutMs ?? 20_000);
118
+ const stableMs = Number(options.stableMs ?? 1_000);
119
+ const requireZero = options.requireZero !== false;
120
+ const deadline = Date.now() + timeoutMs;
121
+ let stableSince = null;
122
+ let last = null;
123
+ while (Date.now() <= deadline) {
124
+ last = await page.evaluate(`(() => {
125
+ const visible = (element) => {
126
+ if (!element) return false;
127
+ const rect = element.getBoundingClientRect();
128
+ const style = getComputedStyle(element);
129
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
130
+ };
131
+ const visibleCount = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
132
+ const balance = document.querySelector('[data-testid="perps-balance-dropdown-balance"]');
133
+ return {
134
+ route: String(location.hash),
135
+ viewVisible: visible(document.querySelector('[data-testid="perps-view"]')),
136
+ loading: visibleCount('[data-testid$="-skeleton"], [data-testid*="loading"]') > 0,
137
+ marketCount: visibleCount('[data-testid="perps-explore-markets-row"]'),
138
+ positionCount: visibleCount('[data-testid^="position-card-"]:not([data-testid^="position-card-roe-"])'),
139
+ orderCount: visibleCount('[data-testid^="order-card-"]'),
140
+ balance: visible(balance) ? String(balance.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 200) : null,
141
+ };
142
+ })()`);
143
+ const loaded = last.route.includes('/perps-home') && last.viewVisible && !last.loading
144
+ && last.marketCount > 0 && typeof last.balance === 'string' && last.balance.length > 0;
145
+ const clean = last.positionCount === 0 && last.orderCount === 0;
146
+ if (loaded && (!requireZero || clean)) {
147
+ stableSince ??= Date.now();
148
+ if (Date.now() - stableSince >= stableMs) {
149
+ const balanceBaseUnits = parseUsdBaseUnits(
150
+ String(last.balance).match(/\$\s*[0-9][0-9,]*(?:\.[0-9]{1,6})?/u)?.[0],
151
+ );
152
+ if (balanceBaseUnits == null) throw new Error('Visible Perps balance could not be parsed.');
153
+ return { ...last, balanceBaseUnits };
154
+ }
155
+ } else {
156
+ stableSince = null;
157
+ }
158
+ await new Promise((resolve) => setTimeout(resolve, 250));
159
+ }
160
+ if (requireZero && last && (last.positionCount !== 0 || last.orderCount !== 0)) {
161
+ throw new Error('Loaded Perps Home must have globally zero positions and orders before external mutation or cleanup.');
162
+ }
163
+ throw new Error('Loaded skeleton-free Perps Home did not become stable before timeout.');
164
+ }