@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,34 +1,142 @@
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
+ readPriorMutationReceipt,
7
+ runGuardedExternalMutation,
8
+ trustedExternalMutationIdentity,
9
+ withExternalMutationRecovery,
10
+ } from './mutation-receipt.mjs';
11
+ import { requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
12
+ import {
13
+ absoluteDelta,
14
+ normalizePositionSizeIdentity,
15
+ normalizedNumericInputMatches,
16
+ parsePositionDetail,
17
+ parseUsdBaseUnits,
18
+ positionIdentityMatches,
19
+ waitForStableGlobalPerpsHome,
20
+ } from './visible-mutation-identity.mjs';
2
21
 
3
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
4
- const market = marketSymbol(input);
5
- const mode = String(input.node?.margin_action ?? 'add').trim().toLowerCase();
6
- const amount = Number(input.node?.amount);
7
- if (!market) throw new Error('metamask.perps.edit_margin requires market=<symbol>.');
8
- if (mode !== 'add' && mode !== 'remove') {
9
- throw new Error(`metamask.perps.edit_margin received unsupported margin_action ${JSON.stringify(mode)}.`);
10
- }
11
- if (!Number.isFinite(amount) || amount <= 0) {
12
- throw new Error('metamask.perps.edit_margin requires a positive amount.');
22
+ function marketSymbol(value) {
23
+ const market = String(value ?? '').trim().toUpperCase();
24
+ if (!/^[A-Z0-9:_-]{2,20}$/u.test(market)) {
25
+ throw new Error('metamask.perps.edit_margin requires market=<symbol>.');
13
26
  }
14
- if (input.node?.confirm_live !== true) {
15
- throw new Error('metamask.perps.edit_margin requires confirm_live=true for live submission.');
27
+ return market;
28
+ }
29
+
30
+ function decimalBaseUnits(value) {
31
+ const units = parseUsdBaseUnits(value);
32
+ if (!/^[1-9][0-9]{0,29}$/u.test(String(units ?? ''))) {
33
+ throw new Error('metamask.perps.edit_margin requires a positive amount with at most six decimals.');
16
34
  }
35
+ return units;
36
+ }
17
37
 
38
+ async function readPosition(page, market) {
18
39
  await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
19
- await page.waitForSelector(dataTestId('perps-position-margin-value'), { timeoutMs: 20_000 });
20
- const before = await page.evaluate(`(() => ({
21
- margin: String(document.querySelector('[data-testid="perps-position-margin-value"]')?.textContent || '').trim(),
22
- liquidation: String(document.querySelector('[data-testid="perps-position-liquidation-value"]')?.textContent || '').trim(),
23
- }))()`);
40
+ await page.waitForSelector(dataTestId('perps-position-size-value'), { timeoutMs: 20_000 });
41
+ const raw = await page.evaluate(`(() => {
42
+ const read = (testId) => String(document.querySelector('[data-testid="' + testId + '"]')?.innerText || '')
43
+ .replace(/\\s+/gu, ' ')
44
+ .trim()
45
+ .slice(0, 500);
46
+ return {
47
+ route: String(location.hash),
48
+ pair: read('perps-market-detail-pair'),
49
+ direction: read('perps-position-leverage'),
50
+ size: read('perps-position-size-value'),
51
+ margin: read('perps-position-margin-value'),
52
+ liquidation: read('perps-position-liquidation-value'),
53
+ };
54
+ })()`);
55
+ const parsed = parsePositionDetail(raw);
56
+ if (!parsed || !raw.route.includes('/perps/market/') || !raw.pair.toUpperCase().startsWith(`${market}-`)) {
57
+ throw new Error('Visible Perps position identity is unavailable or does not match the requested market.');
58
+ }
59
+ return { ...raw, ...parsed };
60
+ }
61
+
62
+ function exactTarget(facts) {
63
+ const baseline = facts?.positionBefore;
64
+ const amountBaseUnits = String(facts?.amountBaseUnits ?? '');
65
+ const mode = String(facts?.marginAction ?? '');
66
+ if (!baseline || !/^[1-9][0-9]{0,29}$/u.test(amountBaseUnits) || !/^(?:add|remove)$/u.test(mode)) {
67
+ throw new Error('Margin receipt has no valid exact baseline.');
68
+ }
69
+ const target = BigInt(baseline.marginBaseUnits) + (mode === 'add' ? 1n : -1n) * BigInt(amountBaseUnits);
70
+ if (target <= 0n) throw new Error('Margin receipt target is invalid.');
71
+ return `${target}`;
72
+ }
73
+
74
+ async function observeMargin(page, identity, facts, balanceTarget, balanceToleranceBaseUnits, timeoutMs) {
75
+ const targetMarginBaseUnits = exactTarget(facts);
76
+ const deadline = Date.now() + timeoutMs;
77
+ let stable = null;
78
+ let stableSince = 0;
79
+ let after;
80
+ while (Date.now() <= deadline) {
81
+ after = await readPosition(page, identity.market);
82
+ const exact = after.marginBaseUnits === targetMarginBaseUnits
83
+ && after.size === facts.positionBefore.size
84
+ && after.direction === facts.positionBefore.direction
85
+ && after.liquidation !== facts.positionBefore.liquidation
86
+ && positionIdentityMatches(after, {
87
+ market: identity.market,
88
+ side: identity.side,
89
+ leverage: identity.leverage,
90
+ matchMargin: false,
91
+ });
92
+ if (exact) {
93
+ const key = JSON.stringify(after);
94
+ if (key !== stable) {
95
+ stable = key;
96
+ stableSince = Date.now();
97
+ } else if (Date.now() - stableSince >= 1_000) break;
98
+ } else {
99
+ stable = null;
100
+ }
101
+ await new Promise((resolve) => setTimeout(resolve, 250));
102
+ }
103
+ if (!stable) throw new Error('exact margin, liquidation, size, and leverage postcondition was not visible');
104
+ await page.navigateHash('#/perps-home');
105
+ const home = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: false });
106
+ const balanceDeltaBaseUnits = balanceTarget == null
107
+ ? null
108
+ : absoluteDelta(home.balanceBaseUnits, balanceTarget);
109
+ if (balanceDeltaBaseUnits != null && BigInt(balanceDeltaBaseUnits) > BigInt(balanceToleranceBaseUnits)) {
110
+ throw new Error('Margin cleanup did not reconcile the visible Perps balance.');
111
+ }
112
+ return {
113
+ success: true,
114
+ positionBefore: facts.positionBefore,
115
+ positionAfter: after,
116
+ targetMarginBaseUnits,
117
+ balanceBeforeBaseUnits: String(facts.balanceBeforeBaseUnits),
118
+ balanceAfterBaseUnits: home.balanceBaseUnits,
119
+ balanceReconciliationTargetBaseUnits: balanceTarget,
120
+ balanceDeltaBaseUnits,
121
+ balanceToleranceBaseUnits,
122
+ };
123
+ }
124
+
125
+ async function prepareMarginModal(page, mode, amount) {
24
126
  await page.click(dataTestId('perps-margin-card'));
25
127
  const menuItem = mode === 'add' ? 'perps-margin-menu-add' : 'perps-margin-menu-remove';
26
128
  await page.waitForSelector(dataTestId(menuItem), { timeoutMs: 10_000 });
27
129
  await page.click(dataTestId(menuItem));
28
130
  const modal = mode === 'add' ? 'perps-add-margin-modal' : 'perps-decrease-margin-modal';
29
131
  await page.waitForSelector(dataTestId(modal), { timeoutMs: 10_000 });
30
- await page.setInput(`${dataTestId('perps-edit-margin-amount-input')} input`, String(amount));
31
-
132
+ const amountSelector = `${dataTestId('perps-edit-margin-amount-input')} input`;
133
+ try {
134
+ await page.setInput(amountSelector, amount);
135
+ } catch (error) {
136
+ if (!/Input did not retain the supplied value length/u.test(String(error?.message ?? error))) throw error;
137
+ const retained = await page.evaluate(`document.querySelector(${JSON.stringify(amountSelector)})?.value ?? null`);
138
+ if (!normalizedNumericInputMatches(retained, amount)) throw error;
139
+ }
32
140
  const saveDeadline = Date.now() + 10_000;
33
141
  let ready;
34
142
  while (Date.now() <= saveDeadline) {
@@ -42,41 +150,174 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
42
150
  liquidation: String(document.querySelector('[data-testid="perps-edit-margin-liquidation-price-value"]')?.textContent || '').trim(),
43
151
  };
44
152
  })()`);
45
- if (ready.saveEnabled) break;
153
+ if (Number(ready.amount) === Number(amount) && ready.saveEnabled) break;
46
154
  await new Promise((resolve) => setTimeout(resolve, 100));
47
155
  }
48
- if (Number(ready?.amount) !== amount || !ready?.saveEnabled) {
156
+ if (!ready?.saveEnabled || Number(ready.amount) !== Number(amount)) {
49
157
  throw new Error(`Perps margin update is not ready: ${JSON.stringify(ready)}.`);
50
158
  }
51
- await page.click(dataTestId('perps-edit-margin-modal-save'));
159
+ return ready;
160
+ }
52
161
 
53
- const resultDeadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
54
- let after;
55
- while (Date.now() <= resultDeadline) {
56
- after = await page.evaluate(`(() => ({
57
- modalVisible: Boolean(document.querySelector(${JSON.stringify(dataTestId(modal))})),
58
- margin: String(document.querySelector('[data-testid="perps-position-margin-value"]')?.textContent || '').trim(),
59
- liquidation: String(document.querySelector('[data-testid="perps-position-liquidation-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
- }))()`);
65
- if (!after.modalVisible && after.margin && after.margin !== before.margin) break;
66
- if (after.errors.length > 0) throw new Error(`Perps margin update failed: ${after.errors.join(' | ')}`);
67
- await new Promise((resolve) => setTimeout(resolve, 200));
162
+ export async function editMargin(input) {
163
+ return withExtensionPage(input, async (page) => {
164
+ const market = marketSymbol(input.node?.market);
165
+ const mode = String(input.node?.margin_action ?? 'add').trim().toLowerCase();
166
+ const amount = String(input.node?.amount ?? '');
167
+ const amountBaseUnits = decimalBaseUnits(amount);
168
+ const fundingSide = String(input.node?.funding_side ?? '').trim().toLowerCase();
169
+ const fundingAmountBaseUnits = decimalBaseUnits(input.node?.funding_amount);
170
+ const fundingPositionSize = normalizePositionSizeIdentity(input.node?.funding_position_size);
171
+ const leverage = Number(input.node?.leverage);
172
+ const balanceToleranceBaseUnits = String(input.node?.balance_reconciliation_tolerance_base_units ?? '1000000');
173
+ if (!/^(?:add|remove)$/u.test(mode) || !/^(?:long|short)$/u.test(fundingSide)
174
+ || fundingPositionSize == null
175
+ || !Number.isInteger(leverage) || leverage < 1 || leverage > 100
176
+ || !/^(?:0|[1-9][0-9]{0,6})$/u.test(balanceToleranceBaseUnits)
177
+ || BigInt(balanceToleranceBaseUnits) > 1_000_000n) {
178
+ throw new Error('metamask.perps.edit_margin received invalid funded position inputs.');
179
+ }
180
+ if (input.node?.confirm_live !== true) {
181
+ throw new Error('metamask.perps.edit_margin requires confirm_live=true for live submission.');
182
+ }
183
+ const identity = trustedExternalMutationIdentity(input, page, {
184
+ operation: 'perps-margin-adjust',
185
+ authorizationCount: 2,
186
+ provider: input.node?.provider,
187
+ network: input.node?.network,
188
+ market,
189
+ side: fundingSide,
190
+ amount,
191
+ leverage,
192
+ orderType: 'margin',
193
+ positionMode: 'modify',
194
+ marginAction: mode,
195
+ positionAmountBaseUnits: fundingAmountBaseUnits,
196
+ positionSize: fundingPositionSize,
197
+ roundingToleranceBaseUnits: '0',
198
+ });
199
+ const existingReceipt = await readExternalMutationReceipt(input, identity);
200
+ let balanceTarget = null;
201
+ if (mode === 'remove') {
202
+ const addReceipt = await readPriorMutationReceipt(
203
+ input,
204
+ identity,
205
+ 'metamask.perps.edit_margin',
206
+ { marginAction: 'add' },
207
+ );
208
+ if (addReceipt.record?.identity?.marginAction !== 'add' || addReceipt.record?.state !== 'succeeded') {
209
+ throw new Error('Margin removal requires one succeeded receipt-bound add operation.');
210
+ }
211
+ balanceTarget = String(addReceipt.record?.facts?.balanceBeforeBaseUnits ?? '');
212
+ if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceTarget)) {
213
+ throw new Error('Prior margin receipt has no valid balance baseline.');
214
+ }
215
+ }
216
+ if (existingReceipt) {
217
+ return withExternalMutationRecovery(existingReceipt, async () => {
218
+ const mutation = await runGuardedExternalMutation({
219
+ input,
220
+ identity,
221
+ dispatch: async () => { throw new Error('resumed margin mutation must not dispatch'); },
222
+ observe: async ({ receipt }) => observeMargin(
223
+ page,
224
+ identity,
225
+ receipt.facts,
226
+ balanceTarget,
227
+ balanceToleranceBaseUnits,
228
+ Number(input.node?.timeout_ms ?? 45_000),
229
+ ),
230
+ });
231
+ return {
232
+ action: input.action,
233
+ market,
234
+ marginAction: mode,
235
+ amount,
236
+ receipt: mutation.receipt,
237
+ resumed: true,
238
+ outcome: mutation.observation,
239
+ proofPath: 'trusted-pointer-visible-ui',
240
+ };
241
+ });
68
242
  }
69
- if (!after || after.modalVisible || after.margin === before.margin) {
70
- throw new Error(`Perps margin did not reconcile after submission: ${JSON.stringify({ before, after })}.`);
243
+
244
+ const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
245
+ const fundingBalance = await requireCurrentVisibleFundingBalance(input, page, timeoutMs);
246
+ const tradingContext = await requirePerpsTradingContext(input, page);
247
+ const before = await readPosition(page, market);
248
+ if (!positionIdentityMatches(before, { market, side: fundingSide, leverage, matchMargin: false })
249
+ || normalizePositionSizeIdentity(before.size) !== fundingPositionSize) {
250
+ throw new Error('Visible Perps position does not match the funded market/side/leverage/size identity.');
71
251
  }
252
+ const ready = await prepareMarginModal(page, mode, amount);
253
+ const facts = {
254
+ marginAction: mode,
255
+ amountBaseUnits,
256
+ balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
257
+ positionBefore: before,
258
+ fundingBalance,
259
+ tradingContext,
260
+ };
261
+ const mutation = await runGuardedExternalMutation({
262
+ input,
263
+ identity,
264
+ publicationFacts: facts,
265
+ dispatch: async () => {
266
+ let dispatchTarget;
267
+ try {
268
+ await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
269
+ await requirePerpsTradingContext(input, page);
270
+ assertTrustedExternalMutationTarget(input, page, identity);
271
+ const current = await readPosition(page, market);
272
+ if (current.marginBaseUnits !== before.marginBaseUnits || current.size !== before.size
273
+ || current.direction !== before.direction
274
+ || normalizePositionSizeIdentity(current.size) !== fundingPositionSize
275
+ || !positionIdentityMatches(current, { market, side: fundingSide, leverage, matchMargin: false })) {
276
+ throw new Error('Visible Perps position changed before margin dispatch.');
277
+ }
278
+ await prepareMarginModal(page, mode, amount);
279
+ dispatchTarget = await page.evaluate(`(() => {
280
+ const element = document.querySelector('[data-testid="perps-edit-margin-modal-save"]');
281
+ if (!element || element.disabled || element.getAttribute('aria-disabled') === 'true') {
282
+ throw new Error('Visible Perps margin submit control is unavailable.');
283
+ }
284
+ const rect = element.getBoundingClientRect();
285
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
286
+ })()`);
287
+ assertTrustedExternalMutationTarget(input, page, identity);
288
+ } catch (error) {
289
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
290
+ }
291
+ await page.session.call('Input.dispatchMouseEvent', {
292
+ type: 'mousePressed', x: dispatchTarget.x, y: dispatchTarget.y,
293
+ button: 'left', buttons: 1, clickCount: 1,
294
+ });
295
+ await page.session.call('Input.dispatchMouseEvent', {
296
+ type: 'mouseReleased', x: dispatchTarget.x, y: dispatchTarget.y,
297
+ button: 'left', buttons: 0, clickCount: 1,
298
+ });
299
+ },
300
+ observe: async ({ receipt }) => observeMargin(
301
+ page,
302
+ identity,
303
+ receipt.facts,
304
+ balanceTarget,
305
+ balanceToleranceBaseUnits,
306
+ Number(input.node?.timeout_ms ?? 45_000),
307
+ ),
308
+ });
72
309
  return {
73
310
  action: input.action,
74
311
  market,
75
312
  marginAction: mode,
76
313
  amount,
77
- before,
78
314
  ready,
79
- after,
80
- proofPath: 'visible-product-controls',
315
+ receipt: mutation.receipt,
316
+ resumed: mutation.resumed,
317
+ outcome: mutation.observation,
318
+ proofPath: 'trusted-pointer-visible-ui',
81
319
  };
82
- }));
320
+ });
321
+ }
322
+
323
+ runAdapter(editMargin);