@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,4 +1,23 @@
1
1
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ assertTrustedExternalMutationTarget,
4
+ externalMutationNotDispatched,
5
+ readExternalMutationReceipt,
6
+ readPriorMutationReceipt,
7
+ runGuardedExternalMutation,
8
+ trustedExternalMutationBudget,
9
+ trustedExternalMutationIdentity,
10
+ trustedExternalMutationPlanIsExact,
11
+ withExternalMutationRecovery,
12
+ } from './mutation-receipt.mjs';
13
+ import { requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
14
+ import {
15
+ absoluteDelta,
16
+ parsePositionDetail,
17
+ parseUsdBaseUnits,
18
+ positionIdentityMatches,
19
+ waitForStableGlobalPerpsHome,
20
+ } from './visible-mutation-identity.mjs';
2
21
 
3
22
  function marketSymbol(value) {
4
23
  const result = String(value ?? '').trim().toUpperCase();
@@ -8,18 +27,10 @@ function marketSymbol(value) {
8
27
  return result;
9
28
  }
10
29
 
11
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
12
- const market = marketSymbol(input.node?.market);
13
- const partialAmount = input.node?.amount == null ? null : Number(input.node.amount);
14
- if (partialAmount != null && (!Number.isFinite(partialAmount) || partialAmount <= 0)) {
15
- throw new Error('metamask.perps.close_visible_position amount must be positive.');
16
- }
17
- if (input.node?.confirm_live !== true) {
18
- throw new Error('metamask.perps.close_visible_position requires confirm_live=true.');
19
- }
30
+ async function readVisibleClosePosition(page, market) {
20
31
  await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
21
32
  await page.waitForSelector(dataTestId('perps-position-size-value'), { timeoutMs: 20_000 });
22
- const before = await page.evaluate(`(() => {
33
+ return page.evaluate(`(() => {
23
34
  const market = ${JSON.stringify(market)};
24
35
  const route = String(location.hash);
25
36
  const symbol = String(document.querySelector('[data-testid="perps-market-detail-pair"]')?.innerText || '')
@@ -42,8 +53,12 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
42
53
  margin: read('perps-position-margin-value'),
43
54
  entryPrice: read('perps-position-entry-value'),
44
55
  liquidationPrice: read('perps-position-liquidation-value'),
56
+ currentPrice: read('perps-market-detail-price'),
45
57
  };
46
58
  })()`);
59
+ }
60
+
61
+ async function prepareCloseModal(page, partialAmount) {
47
62
  let confirmation;
48
63
  for (let attempt = 0; attempt < 3 && !confirmation; attempt += 1) {
49
64
  await page.click(dataTestId('perps-close-cta-button'));
@@ -94,40 +109,287 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
94
109
  }
95
110
  }
96
111
  if (!confirmation.submitEnabled) throw new Error('Perps close confirmation is not submit-ready.');
97
- await page.click(dataTestId('perps-close-position-modal-submit'));
98
- const closeDeadline = Date.now() + 45_000;
99
- let finalState;
100
- while (Date.now() <= closeDeadline) {
101
- finalState = await page.evaluate(`(() => ({
102
- route: String(location.hash),
103
- positionVisible: Boolean(document.querySelector('[data-testid="perps-position-size-value"]')),
104
- positionSize: String(document.querySelector('[data-testid="perps-position-size-value"]')?.innerText || '').trim(),
105
- modalVisible: Boolean(document.querySelector('[data-testid="perps-close-position-modal"]')),
106
- error: String(document.querySelector('[data-testid="perps-order-submit-error"]')?.innerText || '').trim(),
107
- activity: [...document.querySelectorAll('[data-testid^="transaction-card-"]')]
108
- .slice(0, 5)
109
- .map((element) => String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500)),
110
- }))()`);
111
- if (finalState.error) throw new Error(`Perps close failed: ${finalState.error}`);
112
- if (partialAmount != null) {
113
- if (!finalState.modalVisible && finalState.positionVisible && finalState.positionSize !== before.size) break;
114
- } else if (!finalState.positionVisible && !finalState.modalVisible) break;
115
- await new Promise((resolve) => setTimeout(resolve, 250));
116
- }
117
- const incomplete = partialAmount != null
118
- ? finalState?.modalVisible || !finalState?.positionVisible || finalState?.positionSize === before.size
119
- : finalState?.positionVisible || finalState?.modalVisible;
120
- if (incomplete) {
121
- throw new Error('Perps position did not reach the requested close state after the timeout.');
112
+ return confirmation;
113
+ }
114
+
115
+ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
116
+ await page.navigateHash('#/perps-home');
117
+ const state = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: true });
118
+ const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, balanceBeforeBaseUnits);
119
+ if (BigInt(balanceDeltaBaseUnits) > BigInt(toleranceBaseUnits)) {
120
+ throw new Error('Final visible Perps balance exceeds the authorized reconciliation tolerance.');
122
121
  }
122
+ return {
123
+ positionCount: 0,
124
+ orderCount: 0,
125
+ balanceBeforeBaseUnits,
126
+ balanceAfterBaseUnits: state.balanceBaseUnits,
127
+ balanceDeltaBaseUnits,
128
+ toleranceBaseUnits,
129
+ };
130
+ }
131
+
132
+ export async function closeVisiblePosition(input) {
133
+ return withExtensionPage(input, async (page) => {
134
+ const market = marketSymbol(input.node?.market);
135
+ const partialAmount = input.node?.amount == null ? null : Number(input.node.amount);
136
+ if (partialAmount != null && (!Number.isFinite(partialAmount) || partialAmount <= 0)) {
137
+ throw new Error('metamask.perps.close_visible_position amount must be positive.');
138
+ }
139
+ if (input.node?.confirm_live !== true) {
140
+ throw new Error('metamask.perps.close_visible_position requires confirm_live=true.');
141
+ }
142
+ const fundingSide = String(input.node?.funding_side ?? '').toLowerCase();
143
+ const fundingAmount = String(input.node?.funding_amount ?? '');
144
+ const fundingPositionSize = String(input.node?.funding_position_size ?? '');
145
+ const leverage = Number(input.node?.leverage);
146
+ const cleanupOnly = input.node?.cleanup_only === true;
147
+ if (cleanupOnly && !trustedExternalMutationPlanIsExact(input, 'perps-close-position', 1)) {
148
+ throw new Error('Cleanup-only close requires an exact runner-verified close-only request and plan.');
149
+ }
150
+ if (cleanupOnly && (!/^[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u.test(fundingPositionSize)
151
+ || Number.parseFloat(fundingPositionSize) <= 0 || partialAmount != null)) {
152
+ throw new Error('Cleanup-only close requires one exact positive full-position size identity.');
153
+ }
154
+ const roundingToleranceBaseUnits = String(input.node?.identity_rounding_tolerance_base_units ?? '10000');
155
+ const balanceToleranceBaseUnits = String(input.node?.balance_reconciliation_tolerance_base_units ?? '1000000');
156
+ if (!Number.isInteger(leverage) || leverage < 1 || leverage > 100
157
+ || !/^(?:0|[1-9][0-9]{0,6})$/u.test(roundingToleranceBaseUnits)
158
+ || !/^(?:0|[1-9][0-9]{0,6})$/u.test(balanceToleranceBaseUnits)
159
+ || BigInt(roundingToleranceBaseUnits) > 1_000_000n
160
+ || BigInt(balanceToleranceBaseUnits) > 1_000_000n) {
161
+ throw new Error('metamask.perps.close_visible_position leverage/tolerance inputs are invalid.');
162
+ }
163
+ const identity = trustedExternalMutationIdentity(input, page, {
164
+ operation: 'perps-close-position',
165
+ provider: input.node?.provider,
166
+ network: input.node?.network,
167
+ market,
168
+ side: fundingSide,
169
+ amount: fundingAmount,
170
+ leverage,
171
+ orderType: 'close',
172
+ positionMode: 'close',
173
+ limitPrice: null,
174
+ offsetPct: null,
175
+ partialCloseAmount: partialAmount == null ? null : String(input.node.amount),
176
+ roundingToleranceBaseUnits,
177
+ ...(cleanupOnly ? { positionSize: fundingPositionSize, cleanupOnly: true } : {}),
178
+ });
179
+ const existingReceipt = await readExternalMutationReceipt(input, identity);
180
+ if (existingReceipt) {
181
+ return withExternalMutationRecovery(existingReceipt, async () => {
182
+ await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
183
+ await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
184
+ const mutation = await runGuardedExternalMutation({
185
+ input,
186
+ identity,
187
+ dispatch: async () => {
188
+ throw new Error('resumed close must not dispatch');
189
+ },
190
+ observe: async () => {
191
+ const deadline = Date.now() + 45_000;
192
+ let finalState;
193
+ let absentSince = null;
194
+ while (Date.now() <= deadline) {
195
+ finalState = await page.evaluate(`(() => ({
196
+ route: String(location.hash),
197
+ positionVisible: Boolean(document.querySelector('[data-testid="perps-position-size-value"]')),
198
+ positionSize: String(document.querySelector('[data-testid="perps-position-size-value"]')?.innerText || '').trim(),
199
+ modalVisible: Boolean(document.querySelector('[data-testid="perps-close-position-modal"]')),
200
+ loading: Boolean(document.querySelector('[data-testid="perps-detail-page-skeleton"], [data-testid="perps-section-skeleton"]')),
201
+ }))()`);
202
+ if (partialAmount != null && finalState.positionVisible && !finalState.modalVisible
203
+ && finalState.positionSize !== existingReceipt.record?.facts?.positionBeforeSize) {
204
+ absentSince ??= Date.now();
205
+ if (Date.now() - absentSince >= 1_000) return { success: true, finalState, cleanup: null };
206
+ } else if (!finalState.positionVisible && !finalState.modalVisible && !finalState.loading) {
207
+ absentSince ??= Date.now();
208
+ if (Date.now() - absentSince >= 1_000) {
209
+ if (partialAmount != null) return { success: true, finalState, cleanup: null };
210
+ const balanceBeforeBaseUnits = String(existingReceipt.record?.facts?.balanceBeforeBaseUnits ?? '');
211
+ if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceBeforeBaseUnits)) {
212
+ throw new Error('Resumed close receipt has no valid pre-order balance.');
213
+ }
214
+ const cleanup = await proveGlobalCleanup(
215
+ page,
216
+ balanceBeforeBaseUnits,
217
+ balanceToleranceBaseUnits,
218
+ );
219
+ return {
220
+ success: true,
221
+ finalState,
222
+ cleanup,
223
+ };
224
+ }
225
+ } else {
226
+ absentSince = null;
227
+ }
228
+ await new Promise((resolve) => setTimeout(resolve, 250));
229
+ }
230
+ throw new Error('matching closed-position postcondition was not visible before timeout');
231
+ },
232
+ });
233
+ return {
234
+ action: input.action,
235
+ market,
236
+ closed: true,
237
+ partial: false,
238
+ receipt: mutation.receipt,
239
+ resumed: true,
240
+ recovery: mutation.recovery,
241
+ finalState: mutation.observation.finalState,
242
+ cleanup: mutation.observation.cleanup ?? null,
243
+ proofPath: 'trusted-pointer-visible-ui',
244
+ };
245
+ });
246
+ }
247
+ let cleanupHomeBefore = null;
248
+ if (cleanupOnly) {
249
+ await page.navigateHash('#/perps-home');
250
+ cleanupHomeBefore = await waitForStableGlobalPerpsHome(page, { timeoutMs: 20_000, requireZero: false });
251
+ if (cleanupHomeBefore.positionCount !== 1 || cleanupHomeBefore.orderCount !== 0) {
252
+ throw new Error('Cleanup-only close requires exactly one primary position and globally zero orders.');
253
+ }
254
+ }
255
+ const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
256
+ const fundingBalance = await requireCurrentVisibleFundingBalance(input, page, timeoutMs);
257
+ const tradingContext = await requirePerpsTradingContext(input, page);
258
+ const before = await readVisibleClosePosition(page, market);
259
+ const exactBefore = parsePositionDetail({ pair: `${market}-USD`, ...before });
260
+ if (!positionIdentityMatches(exactBefore, {
261
+ market,
262
+ side: fundingSide,
263
+ leverage,
264
+ amountBaseUnits: identity.amountBaseUnits,
265
+ roundingToleranceBaseUnits,
266
+ matchMargin: false,
267
+ })) {
268
+ throw new Error('Visible Perps position does not match the funded market/side/leverage identity.');
269
+ }
270
+ if (cleanupOnly && exactBefore.size !== fundingPositionSize) {
271
+ throw new Error('Cleanup-only close visible position size does not match the authorized exact identity.');
272
+ }
273
+ const sizeMatch = /^([0-9]+(?:\.[0-9]+)?) ([A-Z0-9:_-]{2,20})$/u.exec(String(exactBefore.size ?? ''));
274
+ const priceBaseUnits = parseUsdBaseUnits(before.currentPrice);
275
+ const observedNotionalBaseUnits = sizeMatch && sizeMatch[2] === market && priceBaseUnits != null
276
+ ? `${Math.round(Number(sizeMatch[1]) * Number(priceBaseUnits))}`
277
+ : null;
278
+ const closeBudget = trustedExternalMutationBudget(input, 'perps-close-position');
279
+ if (!/^[1-9][0-9]{0,29}$/u.test(String(observedNotionalBaseUnits ?? ''))
280
+ || BigInt(observedNotionalBaseUnits) > BigInt(closeBudget.maxBaseUnits)) {
281
+ throw new Error('Visible Perps position notional is unavailable or exceeds the authorized close cap.');
282
+ }
283
+ if (!cleanupOnly) {
284
+ await readPriorMutationReceipt(input, identity, 'metamask.perps.place_order');
285
+ }
286
+ const balanceBeforeBaseUnits = fundingBalance.observedBaseUnits;
287
+ if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceBeforeBaseUnits)) {
288
+ throw new Error('Prior order receipt has no valid visible pre-order balance.');
289
+ }
290
+ const confirmation = await prepareCloseModal(page, partialAmount);
291
+ const mutation = await runGuardedExternalMutation({
292
+ input,
293
+ identity,
294
+ publicationFacts: {
295
+ balanceBeforeBaseUnits,
296
+ positionBeforeSize: before.size,
297
+ observedNotionalBaseUnits,
298
+ cleanupOnly,
299
+ fundingBalance,
300
+ tradingContext,
301
+ },
302
+ dispatch: async () => {
303
+ let dispatchTarget;
304
+ try {
305
+ await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
306
+ await requirePerpsTradingContext(input, page);
307
+ assertTrustedExternalMutationTarget(input, page, identity);
308
+ const current = await readVisibleClosePosition(page, market);
309
+ const currentPosition = parsePositionDetail({ pair: `${market}-USD`, ...current });
310
+ if (!positionIdentityMatches(currentPosition, {
311
+ market, side: fundingSide, leverage, amountBaseUnits: identity.amountBaseUnits,
312
+ roundingToleranceBaseUnits, matchMargin: false,
313
+ }) || currentPosition.size !== exactBefore.size) {
314
+ throw new Error('Visible Perps position changed before close dispatch.');
315
+ }
316
+ await prepareCloseModal(page, partialAmount);
317
+ dispatchTarget = await page.evaluate(`(() => {
318
+ const element = document.querySelector('[data-testid="perps-close-position-modal-submit"]');
319
+ if (!element || element.disabled || element.getAttribute('aria-disabled') === 'true') {
320
+ throw new Error('Visible Perps close submit control is unavailable.');
321
+ }
322
+ const rect = element.getBoundingClientRect();
323
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
324
+ })()`);
325
+ assertTrustedExternalMutationTarget(input, page, identity);
326
+ } catch (error) {
327
+ throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
328
+ }
329
+ await page.session.call('Input.dispatchMouseEvent', {
330
+ type: 'mousePressed', x: dispatchTarget.x, y: dispatchTarget.y,
331
+ button: 'left', buttons: 1, clickCount: 1,
332
+ });
333
+ await page.session.call('Input.dispatchMouseEvent', {
334
+ type: 'mouseReleased', x: dispatchTarget.x, y: dispatchTarget.y,
335
+ button: 'left', buttons: 0, clickCount: 1,
336
+ });
337
+ },
338
+ observe: async () => {
339
+ const closeDeadline = Date.now() + 45_000;
340
+ let finalState;
341
+ while (Date.now() <= closeDeadline) {
342
+ finalState = await page.evaluate(`(() => ({
343
+ route: String(location.hash),
344
+ positionVisible: Boolean(document.querySelector('[data-testid="perps-position-size-value"]')),
345
+ positionSize: String(document.querySelector('[data-testid="perps-position-size-value"]')?.innerText || '').trim(),
346
+ modalVisible: Boolean(document.querySelector('[data-testid="perps-close-position-modal"]')),
347
+ error: String(document.querySelector('[data-testid="perps-order-submit-error"]')?.innerText || '').trim(),
348
+ }))()`);
349
+ if (finalState.error) throw new Error('visible Perps close error');
350
+ if (partialAmount != null) {
351
+ if (!finalState.modalVisible && finalState.positionVisible && finalState.positionSize !== before.size) {
352
+ return { success: true, finalState };
353
+ }
354
+ } else if (!finalState.positionVisible && !finalState.modalVisible) {
355
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
356
+ const exactAbsent = !await page.evaluate(
357
+ `Boolean(document.querySelector('[data-testid="perps-position-size-value"]'))`,
358
+ );
359
+ if (exactAbsent) {
360
+ const cleanup = await proveGlobalCleanup(
361
+ page,
362
+ balanceBeforeBaseUnits,
363
+ balanceToleranceBaseUnits,
364
+ );
365
+ return {
366
+ success: true,
367
+ finalState,
368
+ cleanup,
369
+ };
370
+ }
371
+ }
372
+ await new Promise((resolve) => setTimeout(resolve, 250));
373
+ }
374
+ throw new Error('matching closed-position postcondition was not visible before timeout');
375
+ },
376
+ });
123
377
  return {
124
378
  action: input.action,
125
379
  market,
126
380
  closed: partialAmount == null,
127
381
  partial: partialAmount != null,
128
382
  positionBefore: before,
383
+ observedNotionalBaseUnits,
129
384
  confirmation,
130
- finalState,
385
+ finalState: mutation.observation.finalState,
386
+ cleanup: mutation.observation.cleanup ?? null,
387
+ receipt: mutation.receipt,
388
+ resumed: mutation.resumed,
389
+ recovery: mutation.recovery,
131
390
  proofPath: 'trusted-pointer-visible-ui',
132
391
  };
133
- }));
392
+ });
393
+ }
394
+
395
+ runAdapter(closeVisiblePosition);
@@ -18,6 +18,42 @@ function relativeDifference(actual, expected) {
18
18
  return Math.abs(actual - expected) / Math.max(Math.abs(expected), 1e-12);
19
19
  }
20
20
 
21
+ function countdownSeconds(value) {
22
+ const match = String(value ?? '').match(/^(\d{2}):(\d{2}):(\d{2})$/u);
23
+ if (!match) return null;
24
+ return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
25
+ }
26
+
27
+ async function readChartSample(page) {
28
+ return page.evaluate(`(() => {
29
+ const root = document.querySelector('[data-testid="perps-market-detail-chart"]');
30
+ const chart = root?.querySelector('[data-testid="perps-candlestick-chart"]');
31
+ const text = String(root?.innerText || '').replace(/\\s+/gu, ' ').trim();
32
+ const canvases = chart ? [...chart.querySelectorAll('canvas')] : [];
33
+ const samples = canvases.map((canvas) => {
34
+ const context = canvas.getContext('2d');
35
+ if (!context || canvas.width <= 0 || canvas.height <= 0) return { width: canvas.width, height: canvas.height, colors: 0 };
36
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
37
+ const colors = new Set();
38
+ const stride = Math.max(4, Math.floor(pixels.length / 4000 / 4) * 4);
39
+ for (let offset = 0; offset < pixels.length; offset += stride) {
40
+ colors.add(pixels[offset] + ',' + pixels[offset + 1] + ',' + pixels[offset + 2] + ',' + pixels[offset + 3]);
41
+ if (colors.size >= 16) break;
42
+ }
43
+ return { width: canvas.width, height: canvas.height, colors: colors.size };
44
+ });
45
+ const rect = chart?.getBoundingClientRect();
46
+ return {
47
+ loading: Boolean(root && !chart && !/failed to load chart data/iu.test(text)),
48
+ error: /failed to load chart data/iu.test(text),
49
+ visible: Boolean(rect && rect.width > 0 && rect.height > 0),
50
+ canvasCount: canvases.length,
51
+ nonblankCanvasCount: samples.filter((sample) => sample.colors >= 4).length,
52
+ samples,
53
+ };
54
+ })()`);
55
+ }
56
+
21
57
  runAdapter(async (input) => {
22
58
  const market = marketSymbol(input);
23
59
  if (!market) throw new Error('metamask.perps.compare_provider_market requires market=<symbol>.');
@@ -45,6 +81,23 @@ runAdapter(async (input) => {
45
81
  return withExtensionPage(input, async (page) => {
46
82
  await page.navigateHash(`#/perps/market/${encodeURIComponent(market)}`);
47
83
  await page.waitForSelector(dataTestId('perps-market-detail-pair'), { timeoutMs: 20_000 });
84
+ const chartSamples = [];
85
+ const chartDeadline = Date.now() + 15_000;
86
+ let loadingObserved = false;
87
+ while (true) {
88
+ const sample = await readChartSample(page);
89
+ loadingObserved ||= sample.loading;
90
+ if (sample.visible && sample.canvasCount > 0 && sample.nonblankCanvasCount > 0) {
91
+ chartSamples.push(sample);
92
+ break;
93
+ }
94
+ if (sample.error || Date.now() >= chartDeadline) {
95
+ throw new Error('Visible market chart did not resolve to a nonblank canvas.');
96
+ }
97
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
98
+ }
99
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1100))');
100
+ chartSamples.push(await readChartSample(page));
48
101
  const visible = await page.evaluate(`(() => {
49
102
  const read = (testId) => String(document.querySelector('[data-testid="' + testId + '"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
50
103
  const text = read('perps-market-detail-page');
@@ -55,17 +108,26 @@ runAdapter(async (input) => {
55
108
  pair: read('perps-market-detail-pair'),
56
109
  maxLeverage: read('perps-market-max-leverage'),
57
110
  price: read('perps-market-detail-price'),
111
+ change24h: read('perps-market-detail-change'),
58
112
  volume: after('24h Volume'),
59
113
  openInterest: after('Open interest'),
60
114
  fundingRate: after('Funding rate'),
115
+ fundingCountdown: text.match(/Funding rate\\s+[^\\s]+\\s+\\((\\d{2}:\\d{2}:\\d{2})\\)/u)?.[1] ?? null,
61
116
  oraclePrice: read('perps-market-detail-oracle-price'),
62
117
  favoriteLabel: String(document.querySelector('[data-testid="perps-market-detail-favorite-button"]')?.getAttribute('aria-label') || '').trim(),
63
118
  };
64
119
  })()`);
120
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1100))');
121
+ const nextFundingCountdown = await page.evaluate(`(() => {
122
+ const text = String(document.querySelector('[data-testid="perps-market-detail-page"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
123
+ return text.match(/Funding rate\\s+[^\\s]+\\s+\\((\\d{2}:\\d{2}:\\d{2})\\)/u)?.[1] ?? null;
124
+ })()`);
65
125
  const provider = {
66
126
  symbol: metadata.name,
67
127
  maxLeverage: Number(metadata.maxLeverage),
68
128
  markPrice: Number(context.markPx),
129
+ change24hPercent:
130
+ ((Number(context.markPx) - Number(context.prevDayPx)) / Number(context.prevDayPx)) * 100,
69
131
  oraclePrice: Number(context.oraclePx),
70
132
  volume24hUsd: Number(context.dayNtlVlm),
71
133
  openInterestUsd: Number(context.openInterest) * Number(context.markPx),
@@ -73,6 +135,7 @@ runAdapter(async (input) => {
73
135
  };
74
136
  const observed = {
75
137
  price: numeric(visible.price),
138
+ change24hPercent: numeric(visible.change24h),
76
139
  oraclePrice: numeric(visible.oraclePrice),
77
140
  volume24hUsd: compactUsd(visible.volume),
78
141
  openInterestUsd: compactUsd(visible.openInterest),
@@ -85,11 +148,23 @@ runAdapter(async (input) => {
85
148
  name: visible.name.length > 0,
86
149
  maxLeverage: observed.maxLeverage === provider.maxLeverage,
87
150
  price: observed.price != null && relativeDifference(observed.price, provider.markPrice) <= 0.02,
151
+ change24h:
152
+ observed.change24hPercent != null &&
153
+ Number.isFinite(provider.change24hPercent) &&
154
+ Math.abs(observed.change24hPercent - provider.change24hPercent) <= 0.15,
88
155
  oraclePrice: observed.oraclePrice != null && relativeDifference(observed.oraclePrice, provider.oraclePrice) <= 0.02,
89
156
  volume24hUsd: observed.volume24hUsd != null && relativeDifference(observed.volume24hUsd, provider.volume24hUsd) <= 0.05,
90
157
  openInterestUsd: observed.openInterestUsd != null && relativeDifference(observed.openInterestUsd, provider.openInterestUsd) <= 0.05,
91
158
  fundingRate: observed.fundingRate != null && Math.abs(observed.fundingRate - provider.fundingRate) <= 0.00001,
159
+ fundingCountdown:
160
+ countdownSeconds(visible.fundingCountdown) != null &&
161
+ countdownSeconds(nextFundingCountdown) != null &&
162
+ ((countdownSeconds(visible.fundingCountdown) - countdownSeconds(nextFundingCountdown) + 3600) % 3600) >= 1 &&
163
+ ((countdownSeconds(visible.fundingCountdown) - countdownSeconds(nextFundingCountdown) + 3600) % 3600) <= 3,
92
164
  favoriteState: /favorites?/iu.test(visible.favoriteLabel),
165
+ chartNonblank:
166
+ chartSamples.length === 2 &&
167
+ chartSamples.every((sample) => sample.error === false && sample.visible && sample.nonblankCanvasCount > 0),
93
168
  };
94
169
  const failed = Object.entries(checks).filter(([, passed]) => !passed).map(([name]) => name);
95
170
  if (failed.length > 0) {
@@ -102,6 +177,8 @@ runAdapter(async (input) => {
102
177
  visible,
103
178
  observed,
104
179
  provider,
180
+ chart: { loadingObserved, samples: chartSamples },
181
+ nextFundingCountdown,
105
182
  checks,
106
183
  proofPath: 'visible-ui-plus-live-provider',
107
184
  };