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