@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,3 +1,5 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
|
|
1
3
|
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
4
|
import {
|
|
3
5
|
normalizeVisiblePerpsState,
|
|
@@ -16,7 +18,23 @@ const FILTERS = new Map([
|
|
|
16
18
|
['new', 'new'],
|
|
17
19
|
]);
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
async function visibleLabel(page, selector) {
|
|
22
|
+
return page.evaluate(`(() => {
|
|
23
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
24
|
+
if (!element) return { visible: false, label: '' };
|
|
25
|
+
const style = getComputedStyle(element);
|
|
26
|
+
const rect = element.getBoundingClientRect();
|
|
27
|
+
return {
|
|
28
|
+
visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0,
|
|
29
|
+
label: String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
|
|
30
|
+
.replace(/\\s+/gu, ' ')
|
|
31
|
+
.trim()
|
|
32
|
+
.slice(0, 500),
|
|
33
|
+
};
|
|
34
|
+
})()`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function selectMarketFilter(page, input, pollOptions = {}) {
|
|
20
38
|
const filter = String(input.node?.filter ?? '').trim().toLowerCase();
|
|
21
39
|
const option = FILTERS.get(filter);
|
|
22
40
|
if (!option) {
|
|
@@ -29,15 +47,35 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
29
47
|
minimum_market_count: input.node?.minimum_market_count ?? 1,
|
|
30
48
|
maximum_market_count: input.node?.maximum_market_count ?? 200,
|
|
31
49
|
});
|
|
50
|
+
const timeoutMs = pollOptions.timeoutMs ?? input.node?.timeout_ms ?? 10_000;
|
|
51
|
+
const pollIntervalMs = pollOptions.pollIntervalMs ?? 100;
|
|
32
52
|
await page.click(dataTestId('filter-select-button'));
|
|
33
53
|
const optionSelector = dataTestId(`filter-select-option-${option}`);
|
|
34
54
|
try {
|
|
35
|
-
await page.waitForSelector(optionSelector, { timeoutMs: 2_000 });
|
|
55
|
+
await page.waitForSelector(optionSelector, { timeoutMs: Math.min(timeoutMs, 2_000) });
|
|
36
56
|
} catch {
|
|
37
57
|
throw new Error(`Perps market filter ${JSON.stringify(filter)} is not available in this release.`);
|
|
38
58
|
}
|
|
59
|
+
const optionState = await visibleLabel(page, optionSelector);
|
|
60
|
+
if (!optionState.visible || !optionState.label) {
|
|
61
|
+
throw new Error(`Perps market filter ${JSON.stringify(filter)} is not visibly available in this release.`);
|
|
62
|
+
}
|
|
39
63
|
await page.click(optionSelector);
|
|
40
|
-
|
|
64
|
+
const selectedSelector = dataTestId('filter-select-button');
|
|
65
|
+
const deadline = Date.now() + timeoutMs;
|
|
66
|
+
let selectedState;
|
|
67
|
+
while (true) {
|
|
68
|
+
selectedState = await visibleLabel(page, selectedSelector);
|
|
69
|
+
if (selectedState.visible && selectedState.label === optionState.label) break;
|
|
70
|
+
if (Date.now() >= deadline) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Perps market filter ${JSON.stringify(filter)} did not select within ${timeoutMs}ms: ` +
|
|
73
|
+
`expected label ${JSON.stringify(optionState.label)}, observed ${JSON.stringify(selectedState.label)} ` +
|
|
74
|
+
`(visible=${selectedState.visible === true}).`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${JSON.stringify(pollIntervalMs)}))`);
|
|
78
|
+
}
|
|
41
79
|
const raw = await page.evaluate(`(() => {
|
|
42
80
|
const maximum = ${JSON.stringify(options.maxItems)};
|
|
43
81
|
const items = [];
|
|
@@ -66,7 +104,13 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
66
104
|
return {
|
|
67
105
|
action: input.action,
|
|
68
106
|
filter,
|
|
107
|
+
option,
|
|
108
|
+
selectedLabel: selectedState.label,
|
|
69
109
|
...normalizeVisiblePerpsState(raw, options, 'extension'),
|
|
70
110
|
proofPath: 'trusted-pointer-visible-ui',
|
|
71
111
|
};
|
|
72
|
-
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
115
|
+
runAdapter((input) => withExtensionPage(input, (page) => selectMarketFilter(page, input)));
|
|
116
|
+
}
|
|
@@ -9,6 +9,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
9
9
|
throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
|
|
10
10
|
}
|
|
11
11
|
const desired = input.node.favorite;
|
|
12
|
+
const requireUnchanged = input.node?.require_unchanged === true;
|
|
12
13
|
const selector = dataTestId('perps-market-detail-favorite-button');
|
|
13
14
|
const readState = () => page.evaluate(`(() => {
|
|
14
15
|
const market = ${JSON.stringify(market)};
|
|
@@ -35,11 +36,15 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
35
36
|
if (after.favorite !== desired) {
|
|
36
37
|
throw new Error(`Perps favorite state did not become ${desired}.`);
|
|
37
38
|
}
|
|
39
|
+
const changed = before.favorite !== after.favorite;
|
|
40
|
+
if (requireUnchanged && changed) {
|
|
41
|
+
throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
|
|
42
|
+
}
|
|
38
43
|
return {
|
|
39
44
|
action: input.action,
|
|
40
45
|
market,
|
|
41
46
|
requestedFavorite: desired,
|
|
42
|
-
changed
|
|
47
|
+
changed,
|
|
43
48
|
before,
|
|
44
49
|
after,
|
|
45
50
|
proofPath: 'trusted-pointer-visible-ui',
|
|
@@ -1,49 +1,131 @@
|
|
|
1
|
-
import { dataTestId,
|
|
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
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
92
|
+
throw new Error(`exact TP/SL postcondition was not visible: ${JSON.stringify(after)}.`);
|
|
93
|
+
}
|
|
31
94
|
|
|
32
|
-
|
|
33
|
-
await page
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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.
|
|
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
|
-
|
|
149
|
+
return { prefill, ready };
|
|
150
|
+
}
|
|
75
151
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 (
|
|
96
|
-
throw new Error(
|
|
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
|
-
|
|
104
|
-
|
|
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
|
+
}
|