@deeeed/metamask-harness 0.39.0 → 0.40.1

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 (67) hide show
  1. package/CHANGELOG.md +52 -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 +8 -7
  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 +1393 -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/networks/add_chainlist.mjs +4 -0
  20. package/library/actions/extension/networks/add_custom.mjs +4 -0
  21. package/library/actions/extension/networks/custom_network.mjs +48 -1
  22. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  23. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  24. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  25. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  26. package/library/actions/extension/perps/mutation-receipt.mjs +785 -0
  27. package/library/actions/extension/perps/perps.mjs +1029 -363
  28. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  29. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  30. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  31. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  32. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  33. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  34. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  35. package/library/actions/extension/platform/cdp.mjs +88 -9
  36. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  37. package/library/actions/extension/ui/navigate.mjs +45 -5
  38. package/library/actions/extension/wallet/import.mjs +70 -29
  39. package/library/actions/extension/wallet/lock.mjs +62 -5
  40. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  41. package/library/actions/mobile/ui/native-navigation.mjs +45 -0
  42. package/library/actions/mobile/wallet/import.mjs +17 -10
  43. package/library/actions/mobile/wallet/native-ui.mjs +21 -1
  44. package/library/actions/shared/perps/visible-state.mjs +25 -5
  45. package/library/manifests/extension.action-manifest.json +1696 -1271
  46. package/library/recipes/extension/assets/release-custom-network-token-persistence.recipe.json +1 -0
  47. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  48. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  49. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  50. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  51. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  52. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  53. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  54. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  55. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  56. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  57. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  58. package/library/recipes/wallet/import.recipe.json +4 -14
  59. package/package.json +1 -1
  60. package/library/actions/extension/assets/finish_send.mjs +0 -128
  61. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  62. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  63. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  64. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  65. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  66. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  67. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -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
+ }