@deeeed/metamask-harness 0.36.0 → 0.38.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 +51 -0
- package/adapters/extension/artifact-runtime-state.cjs +138 -0
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +24 -0
- package/adapters/extension/live.sh +82 -12
- package/adapters/extension/readiness.mjs +77 -36
- package/adapters/extension/snapshot-dist.sh +88 -3
- package/adapters/extension/verify.sh +6 -2
- package/adapters/manifest.json +17 -1
- package/adapters/shared/log-tui.mjs +1 -1
- package/dist/adapters/extension/artifact-integrity.js +38 -0
- package/dist/adapters/extension/extension-id.js +23 -4
- package/dist/adapters/extension/release-artifact.js +386 -0
- package/dist/adapters/extension/runtime-decision.js +164 -20
- package/dist/adapters/extension/runtime.js +169 -3
- package/dist/adapters/mobile/provision.js +38 -3
- package/dist/adapters/mobile/release-artifact-state.js +124 -0
- package/dist/adapters/mobile/release-artifact.js +295 -0
- package/dist/adapters.js +78 -6
- package/dist/app-lifecycle.js +4 -3
- package/dist/command-contract.js +16 -0
- package/dist/commands/call.js +6 -2
- package/dist/commands/launch/extension.js +74 -9
- package/dist/commands/launch/index.js +3 -0
- package/dist/commands/launch/mobile.js +2 -0
- package/dist/commands/provision.js +2 -0
- package/dist/commands/run-engine.js +109 -14
- package/dist/commands/run.js +4 -2
- package/dist/commands/runtime-launch.js +178 -10
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +47 -1
- package/dist/mm-harness-cli.js +31 -1
- package/dist/recipe-security.js +40 -0
- package/dist/run-diagnostics.js +1 -1
- package/dist/runner.js +3 -0
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
- package/library/actions/extension/assets/finish_send.mjs +128 -0
- package/library/actions/extension/assets/import_custom_token.mjs +227 -0
- package/library/actions/extension/assets/prepare_send.mjs +53 -0
- package/library/actions/extension/assets/read_details.mjs +57 -0
- package/library/actions/extension/assets/read_visible_state.mjs +50 -0
- package/library/actions/extension/assets/review_send.mjs +23 -0
- package/library/actions/extension/assets/select_network_scope.mjs +30 -0
- package/library/actions/extension/assets/send.mjs +104 -0
- package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
- package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
- package/library/actions/extension/assets/verify_sorting.mjs +89 -0
- package/library/actions/extension/networks/add_chainlist.mjs +94 -0
- package/library/actions/extension/networks/add_custom.mjs +123 -0
- package/library/actions/extension/networks/custom_network.mjs +103 -0
- package/library/actions/extension/networks/read_visible_state.mjs +50 -0
- package/library/actions/extension/networks/remove_custom.mjs +68 -0
- package/library/actions/extension/perps/close_orders.mjs +1 -0
- package/library/actions/extension/perps/close_visible_position.mjs +133 -0
- package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
- package/library/actions/extension/perps/edit_margin.mjs +82 -0
- package/library/actions/extension/perps/open_balance_action.mjs +58 -0
- package/library/actions/extension/perps/open_position_action.mjs +80 -0
- package/library/actions/extension/perps/perps.mjs +196 -2
- package/library/actions/extension/perps/place_order.mjs +5 -2
- package/library/actions/extension/perps/read_eligibility.mjs +34 -0
- package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
- package/library/actions/extension/perps/read_snapshot.mjs +470 -0
- package/library/actions/extension/perps/read_visible_state.mjs +98 -0
- package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
- package/library/actions/extension/perps/select_market_filter.mjs +71 -0
- package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
- package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
- package/library/actions/extension/platform/cdp.mjs +109 -3
- package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
- package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
- package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
- package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
- package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
- package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
- package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
- package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
- package/library/actions/extension/ui/navigate.mjs +264 -8
- package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
- package/library/actions/extension/wallet/import.mjs +13 -46
- package/library/actions/extension/wallet/lock.mjs +35 -0
- package/library/actions/extension/wallet/secret-input.mjs +98 -0
- package/library/actions/extension/wallet/select_account.mjs +71 -7
- package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
- package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
- package/library/actions/mobile/platform/native-session-name.mjs +5 -0
- package/library/actions/mobile/platform/observe-ui.mjs +86 -2
- package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
- package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
- package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
- package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
- package/library/actions/mobile/ui/native-navigation.mjs +265 -0
- package/library/actions/mobile/ui/navigate.mjs +10 -1
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
- package/library/actions/mobile/wallet/import.mjs +71 -2
- package/library/actions/mobile/wallet/native-ui.mjs +512 -0
- package/library/actions/mobile/wallet/read_state.mjs +16 -0
- package/library/actions/mobile/wallet/reset.mjs +17 -4
- package/library/actions/shared/assets/visible-state.mjs +176 -0
- package/library/actions/shared/networks/visible-state.mjs +70 -0
- package/library/actions/shared/perps/visible-state.mjs +354 -0
- package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
- package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
- package/library/actions/shared/ui/locators.mjs +7 -0
- package/library/manifests/extension.action-manifest.json +1366 -184
- package/library/manifests/mobile.action-manifest.json +235 -1
- package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
- package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
- package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
- package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
- package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
- package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
- package/library/recipes/assets/release-token-details.recipe.json +48 -0
- package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
- package/library/recipes/assets/release-token-list.recipe.json +75 -0
- package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
- package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
- package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
- package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
- package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
- package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
- package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
- package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
- package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
- package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
- package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
- package/library/recipes/perps/release-market-details.recipe.json +83 -0
- package/library/recipes/perps/release-market-search.recipe.json +79 -0
- package/library/recipes/perps/release-watchlist.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
- package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
- package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
- package/library/recipes/wallet/import.recipe.json +20 -1
- package/library/recipes/wallet/reset-import.recipe.json +20 -1
- package/package.json +6 -5
- package/scripts/validate-release-report.mjs +319 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { activityItem, bodyState, selectors, tokenRow, transactionState, visible, waitFor } from './send.mjs';
|
|
3
|
+
|
|
4
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
5
|
+
const decision = String(input.node?.decision || '').trim();
|
|
6
|
+
const symbol = String(input.node?.symbol || '').trim().toUpperCase();
|
|
7
|
+
const amount = String(input.node?.amount || '').trim();
|
|
8
|
+
const resumeSubmitted = input.node?.resume_submitted === true;
|
|
9
|
+
const resumedHash = String(input.node?.transaction_hash || '').trim();
|
|
10
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 120_000);
|
|
11
|
+
if (!['confirm', 'cancel'].includes(decision) || !symbol || !amount) {
|
|
12
|
+
throw new Error('metamask.assets.finish_send requires decision=confirm|cancel, symbol, and amount.');
|
|
13
|
+
}
|
|
14
|
+
if (decision === 'confirm' && input.node?.confirm_live !== true) {
|
|
15
|
+
throw new Error('metamask.assets.finish_send decision=confirm requires confirm_live=true.');
|
|
16
|
+
}
|
|
17
|
+
const control = decision === 'confirm' ? selectors.confirm : selectors.cancel;
|
|
18
|
+
let state = await bodyState(page);
|
|
19
|
+
let submittedTransaction = null;
|
|
20
|
+
let submittedNow = false;
|
|
21
|
+
if (await visible(page, control)) {
|
|
22
|
+
const beforeTransactions = decision === 'confirm' ? await transactionState(page) : null;
|
|
23
|
+
if (decision === 'confirm' && !beforeTransactions?.available) {
|
|
24
|
+
throw new Error('Transaction state is unavailable; refusing to submit without proof binding.');
|
|
25
|
+
}
|
|
26
|
+
const beforeById = new Map((beforeTransactions?.transactions ?? []).map((transaction) => [transaction.id, transaction]));
|
|
27
|
+
await page.click(control);
|
|
28
|
+
submittedNow = decision === 'confirm';
|
|
29
|
+
state = await waitFor(page, async () => {
|
|
30
|
+
const current = await bodyState(page);
|
|
31
|
+
return current.route === '' || current.route === '#/' ? current : null;
|
|
32
|
+
}, 'Wallet did not return after send decision.', timeoutMs);
|
|
33
|
+
if (decision === 'confirm') {
|
|
34
|
+
submittedTransaction = await waitFor(page, async () => {
|
|
35
|
+
const current = await transactionState(page);
|
|
36
|
+
const changed = current.transactions.filter((transaction) => {
|
|
37
|
+
const before = beforeById.get(transaction.id);
|
|
38
|
+
if (!before) return true;
|
|
39
|
+
const wasAwaitingApproval = ['unapproved', 'approved'].includes(String(before.status).toLowerCase());
|
|
40
|
+
return wasAwaitingApproval && (before.status !== transaction.status || before.hash !== transaction.hash);
|
|
41
|
+
});
|
|
42
|
+
if (changed.length > 1) {
|
|
43
|
+
throw new Error(`Send submission changed multiple transaction records: ${changed.map((transaction) => transaction.id).join(', ')}.`);
|
|
44
|
+
}
|
|
45
|
+
return changed[0] ?? null;
|
|
46
|
+
}, 'Send confirmation did not change one transaction record.', timeoutMs);
|
|
47
|
+
}
|
|
48
|
+
} else if (decision === 'cancel') {
|
|
49
|
+
throw new Error('Send cancel control was not visible.');
|
|
50
|
+
} else if (!resumeSubmitted) {
|
|
51
|
+
throw new Error('Send confirm control was not visible. Next: rerun with resume_submitted=true only if this exact transaction was already submitted.');
|
|
52
|
+
} else if (!resumedHash) {
|
|
53
|
+
throw new Error('metamask.assets.finish_send resume_submitted=true requires transaction_hash.');
|
|
54
|
+
} else if (!/^0x[0-9a-f]{64}$/iu.test(resumedHash)) {
|
|
55
|
+
throw new Error('metamask.assets.finish_send transaction_hash must be a full EVM transaction hash.');
|
|
56
|
+
}
|
|
57
|
+
if (decision === 'cancel') {
|
|
58
|
+
return { action: input.action, decision, cancelled: true, proofPath: 'trusted-pointer-visible-ui' };
|
|
59
|
+
}
|
|
60
|
+
submittedTransaction = await waitFor(page, async () => {
|
|
61
|
+
const current = await transactionState(page);
|
|
62
|
+
const transaction = submittedNow
|
|
63
|
+
? current.transactions.find((entry) => entry.id === submittedTransaction?.id)
|
|
64
|
+
: current.transactions.find((entry) => entry.hash?.toLowerCase() === resumedHash.toLowerCase());
|
|
65
|
+
if (!transaction) return null;
|
|
66
|
+
if (['failed', 'rejected', 'dropped'].includes(String(transaction.status).toLowerCase())) {
|
|
67
|
+
throw new Error(`Send transaction ${transaction.id} reached ${transaction.status}.`);
|
|
68
|
+
}
|
|
69
|
+
return String(transaction.status).toLowerCase() === 'confirmed' && /^0x[0-9a-f]{64}$/iu.test(transaction.hash || '')
|
|
70
|
+
? transaction
|
|
71
|
+
: null;
|
|
72
|
+
}, `Sent ${amount} ${symbol} did not produce one confirmed transaction record.`, timeoutMs);
|
|
73
|
+
let tokenAfter = null;
|
|
74
|
+
if (!state.route.includes('/tx/')) {
|
|
75
|
+
if (state.route.includes('/activity')) {
|
|
76
|
+
await page.click(selectors.home);
|
|
77
|
+
}
|
|
78
|
+
tokenAfter = await waitFor(page, () => tokenRow(page, symbol), `${symbol} did not reappear after send.`, timeoutMs);
|
|
79
|
+
await waitFor(page, async () => {
|
|
80
|
+
const current = await bodyState(page);
|
|
81
|
+
if (current.route.includes('/activity') || current.route.includes('/tx/')) return current;
|
|
82
|
+
await page.click(selectors.activity);
|
|
83
|
+
return null;
|
|
84
|
+
}, 'Activity did not open after send.', timeoutMs);
|
|
85
|
+
}
|
|
86
|
+
let activity = null;
|
|
87
|
+
if (!state.route.includes('/tx/')) {
|
|
88
|
+
activity = await waitFor(page, () => activityItem(page, symbol, amount), `Sent ${amount} ${symbol} did not appear in Activity.`, timeoutMs);
|
|
89
|
+
await waitFor(page, async () => {
|
|
90
|
+
const current = await bodyState(page);
|
|
91
|
+
if (current.route.includes('/tx/')) return current;
|
|
92
|
+
const currentActivity = await activityItem(page, symbol, amount);
|
|
93
|
+
if (currentActivity) {
|
|
94
|
+
activity = currentActivity;
|
|
95
|
+
await page.click(currentActivity.selector);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}, `Sent ${amount} ${symbol} transaction details did not open.`, timeoutMs);
|
|
99
|
+
}
|
|
100
|
+
const details = await waitFor(page, async () => {
|
|
101
|
+
const current = await bodyState(page);
|
|
102
|
+
if (!current.route.includes('/tx/') || !/\bConfirmed\b/u.test(current.text)) return null;
|
|
103
|
+
return current;
|
|
104
|
+
}, `Sent ${amount} ${symbol} did not reach Confirmed.`, timeoutMs);
|
|
105
|
+
const explorerUrl = await page.evaluate(`document.querySelector('[data-testid="transaction-details-block-explorer"]')?.getAttribute('data-explorer-url') || null`);
|
|
106
|
+
const transactionHash = details.route.match(/\/(0x[0-9a-f]{64})(?:\?|$)/iu)?.[1]
|
|
107
|
+
?? details.text.match(/\b0x[0-9a-f]{64}\b/iu)?.[0]
|
|
108
|
+
?? String(explorerUrl || '').match(/\/(0x[0-9a-f]{64})(?:[/?#]|$)/iu)?.[1]
|
|
109
|
+
?? null;
|
|
110
|
+
if (!transactionHash) throw new Error('Confirmed transaction details did not expose a full transaction hash.');
|
|
111
|
+
if (transactionHash.toLowerCase() !== submittedTransaction.hash.toLowerCase()) {
|
|
112
|
+
throw new Error(`Confirmed Activity details opened ${transactionHash}, expected ${submittedTransaction.hash}.`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
action: input.action,
|
|
116
|
+
decision,
|
|
117
|
+
symbol,
|
|
118
|
+
amount,
|
|
119
|
+
activity,
|
|
120
|
+
tokenAfter,
|
|
121
|
+
details,
|
|
122
|
+
explorerUrl,
|
|
123
|
+
transactionHash,
|
|
124
|
+
transactionId: submittedTransaction.id,
|
|
125
|
+
resumed: !submittedNow,
|
|
126
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
127
|
+
};
|
|
128
|
+
}));
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
|
|
3
|
+
const OPTIONS = dataTestId('asset-list-control-bar-action-button');
|
|
4
|
+
const MANAGE = dataTestId('manageTokens__button');
|
|
5
|
+
const ADD = dataTestId('token-management-add-custom-token-button');
|
|
6
|
+
const PAGE = dataTestId('custom-token-import-page');
|
|
7
|
+
const NETWORK = dataTestId('network-selector');
|
|
8
|
+
const ADDRESS = dataTestId('custom-token-import-address-input');
|
|
9
|
+
const SYMBOL = dataTestId('custom-token-import-symbol-input');
|
|
10
|
+
const DECIMALS = dataTestId('custom-token-import-decimal-input');
|
|
11
|
+
const SUBMIT = dataTestId('custom-token-import-submit-button');
|
|
12
|
+
const SUCCESS = dataTestId('token-management-custom-token-success-toast');
|
|
13
|
+
const BACK = dataTestId('token-management-header-back-button');
|
|
14
|
+
const LOW_VALUE = dataTestId('low-value-assets-toggle');
|
|
15
|
+
|
|
16
|
+
async function visible(page, selector) {
|
|
17
|
+
return page.evaluate(`(() => {
|
|
18
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
19
|
+
if (!element) return false;
|
|
20
|
+
const style = getComputedStyle(element);
|
|
21
|
+
const rect = element.getBoundingClientRect();
|
|
22
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
23
|
+
})()`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function waitFor(page, predicate, message, timeoutMs) {
|
|
27
|
+
const deadline = Date.now() + timeoutMs;
|
|
28
|
+
while (Date.now() < deadline) {
|
|
29
|
+
const value = await predicate();
|
|
30
|
+
if (value) return value;
|
|
31
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 150))');
|
|
32
|
+
}
|
|
33
|
+
throw new Error(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function visibleButton(page, label) {
|
|
37
|
+
const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
38
|
+
const found = await page.evaluate(`(() => {
|
|
39
|
+
const label = ${JSON.stringify(label)};
|
|
40
|
+
const marker = ${JSON.stringify(marker)};
|
|
41
|
+
for (const element of document.querySelectorAll('button')) {
|
|
42
|
+
const text = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
|
|
43
|
+
const style = getComputedStyle(element);
|
|
44
|
+
const rect = element.getBoundingClientRect();
|
|
45
|
+
if (text === label && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) {
|
|
46
|
+
element.setAttribute('data-mm-harness-target', marker);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
})()`);
|
|
52
|
+
if (!found) throw new Error(`Visible button ${JSON.stringify(label)} was not found.`);
|
|
53
|
+
return `[data-mm-harness-target="${marker}"]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function networkOption(page, network, chainId) {
|
|
57
|
+
return page.evaluate(`(() => {
|
|
58
|
+
const network = ${JSON.stringify(network.toLowerCase())};
|
|
59
|
+
const chainIds = new Set(${JSON.stringify([
|
|
60
|
+
String(chainId),
|
|
61
|
+
`0x${chainId.toString(16)}`,
|
|
62
|
+
`eip155:${chainId}`,
|
|
63
|
+
].map((value) => value.toLowerCase()))});
|
|
64
|
+
for (const element of document.querySelectorAll('[data-testid^="network-list-item-"]')) {
|
|
65
|
+
const testId = String(element.getAttribute('data-testid') || '');
|
|
66
|
+
const suffix = testId.slice('network-list-item-'.length).toLowerCase();
|
|
67
|
+
const label = String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
|
|
68
|
+
if (chainIds.has(suffix) || label.toLowerCase().includes(network)) return { testId, label };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
})()`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function homeToken(page, symbol) {
|
|
75
|
+
return page.evaluate(`(() => {
|
|
76
|
+
const symbol = ${JSON.stringify(symbol.toLowerCase())};
|
|
77
|
+
for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
|
|
78
|
+
const name = String(row.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
|
|
79
|
+
const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
|
|
80
|
+
const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1] || '';
|
|
81
|
+
if (observed.toLowerCase() === symbol) return { name, symbol: observed.toUpperCase(), amount };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
})()`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function importedTokenIdentity(page, chainId, address, expectedSymbol) {
|
|
88
|
+
return page.evaluate(`(async () => {
|
|
89
|
+
const hooks = globalThis.stateHooks || {};
|
|
90
|
+
const root = (await hooks.getCleanAppState?.()) || hooks.store?.getState?.() || {};
|
|
91
|
+
const state = root.metamask || root;
|
|
92
|
+
const allTokens = state.allTokens;
|
|
93
|
+
const selectedAddress = String(state.selectedAddress || '').toLowerCase();
|
|
94
|
+
if (!allTokens || typeof allTokens !== 'object' || !selectedAddress) {
|
|
95
|
+
return { available: false, token: null };
|
|
96
|
+
}
|
|
97
|
+
const expectedChainId = ${JSON.stringify(chainId)};
|
|
98
|
+
const chainEntry = Object.entries(allTokens).find(([key]) => {
|
|
99
|
+
const normalized = String(key).toLowerCase();
|
|
100
|
+
const value = normalized.startsWith('eip155:')
|
|
101
|
+
? Number(normalized.slice('eip155:'.length))
|
|
102
|
+
: normalized.startsWith('0x')
|
|
103
|
+
? Number.parseInt(normalized.slice(2), 16)
|
|
104
|
+
: Number(normalized);
|
|
105
|
+
return value === expectedChainId;
|
|
106
|
+
});
|
|
107
|
+
const accountEntry = chainEntry && Object.entries(chainEntry[1] || {})
|
|
108
|
+
.find(([account]) => String(account).toLowerCase() === selectedAddress);
|
|
109
|
+
const tokens = Array.isArray(accountEntry?.[1]) ? accountEntry[1] : [];
|
|
110
|
+
const expectedAddress = ${JSON.stringify(address.toLowerCase())};
|
|
111
|
+
const expectedSymbol = ${JSON.stringify(expectedSymbol)};
|
|
112
|
+
const token = tokens.find((entry) => {
|
|
113
|
+
const tokenAddress = String(entry?.address || entry?.contractAddress || '').toLowerCase();
|
|
114
|
+
return tokenAddress === expectedAddress && String(entry?.symbol || '').toUpperCase() === expectedSymbol;
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
available: true,
|
|
118
|
+
token: token ? {
|
|
119
|
+
address: String(token.address || token.contractAddress),
|
|
120
|
+
symbol: String(token.symbol),
|
|
121
|
+
decimals: token.decimals == null ? null : String(token.decimals),
|
|
122
|
+
chainId: chainEntry?.[0] || null,
|
|
123
|
+
} : null,
|
|
124
|
+
};
|
|
125
|
+
})()`, { awaitPromise: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
129
|
+
const network = String(input.node?.network || '').trim();
|
|
130
|
+
const chainId = Number(input.node?.chain_id);
|
|
131
|
+
const address = String(input.node?.address || '').trim();
|
|
132
|
+
const expectedSymbol = String(input.node?.expected_symbol || '').trim().toUpperCase();
|
|
133
|
+
const expectedDecimals = input.node?.decimals == null ? null : String(input.node.decimals);
|
|
134
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
135
|
+
if (!network || !Number.isInteger(chainId) || chainId <= 0 || !/^0x[0-9a-f]{40}$/iu.test(address) || !expectedSymbol) {
|
|
136
|
+
throw new Error('metamask.assets.import_custom_token requires network, positive chain_id, ERC-20 address, and expected_symbol.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const existingToken = await homeToken(page, expectedSymbol);
|
|
140
|
+
const existingIdentity = existingToken
|
|
141
|
+
? await importedTokenIdentity(page, chainId, address, expectedSymbol)
|
|
142
|
+
: null;
|
|
143
|
+
if (existingToken && existingIdentity?.token) {
|
|
144
|
+
return {
|
|
145
|
+
action: input.action,
|
|
146
|
+
network,
|
|
147
|
+
chainId,
|
|
148
|
+
address,
|
|
149
|
+
alreadyImported: true,
|
|
150
|
+
token: existingToken,
|
|
151
|
+
identity: existingIdentity.token,
|
|
152
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
|
|
157
|
+
await waitFor(page, () => visible(page, MANAGE), 'Manage tokens did not become visible.', timeoutMs);
|
|
158
|
+
await page.click(await visibleButton(page, 'Manage tokens'));
|
|
159
|
+
await waitFor(page, () => visible(page, ADD), 'Add a custom token did not become visible.', timeoutMs);
|
|
160
|
+
await page.click(await visibleButton(page, 'Add a custom token'));
|
|
161
|
+
await waitFor(page, () => visible(page, PAGE), 'Custom token import did not open.', timeoutMs);
|
|
162
|
+
await page.click(NETWORK);
|
|
163
|
+
const selectedNetwork = await waitFor(
|
|
164
|
+
page,
|
|
165
|
+
() => networkOption(page, network, chainId),
|
|
166
|
+
`${network} (${chainId}) was not available in the custom token network selector.`,
|
|
167
|
+
timeoutMs,
|
|
168
|
+
);
|
|
169
|
+
await page.click(dataTestId(selectedNetwork.testId));
|
|
170
|
+
await waitFor(page, () => visible(page, ADDRESS), 'Custom token address input did not become visible.', timeoutMs);
|
|
171
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 2000))');
|
|
172
|
+
await page.setInput(ADDRESS, address);
|
|
173
|
+
|
|
174
|
+
let metadataSource = 'network';
|
|
175
|
+
let metadata;
|
|
176
|
+
try {
|
|
177
|
+
metadata = await waitFor(page, async () => {
|
|
178
|
+
const state = await page.evaluate(`(() => ({
|
|
179
|
+
symbol: document.querySelector(${JSON.stringify(SYMBOL)})?.value || null,
|
|
180
|
+
decimals: document.querySelector(${JSON.stringify(DECIMALS)})?.value || null,
|
|
181
|
+
enabled: !document.querySelector(${JSON.stringify(SUBMIT)})?.disabled,
|
|
182
|
+
}))()`);
|
|
183
|
+
return state.symbol && state.enabled ? state : null;
|
|
184
|
+
}, 'Custom token metadata did not resolve to an enabled import.', Math.min(timeoutMs, 15_000));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (expectedDecimals === null) throw error;
|
|
187
|
+
metadataSource = 'manual';
|
|
188
|
+
await page.setInput(SYMBOL, expectedSymbol);
|
|
189
|
+
await page.setInput(DECIMALS, expectedDecimals);
|
|
190
|
+
metadata = await waitFor(page, async () => {
|
|
191
|
+
const state = await page.evaluate(`(() => ({
|
|
192
|
+
symbol: document.querySelector(${JSON.stringify(SYMBOL)})?.value || null,
|
|
193
|
+
decimals: document.querySelector(${JSON.stringify(DECIMALS)})?.value || null,
|
|
194
|
+
enabled: !document.querySelector(${JSON.stringify(SUBMIT)})?.disabled,
|
|
195
|
+
}))()`);
|
|
196
|
+
return state.enabled ? state : null;
|
|
197
|
+
}, 'Manually supplied token metadata did not enable the import.', timeoutMs);
|
|
198
|
+
}
|
|
199
|
+
if (String(metadata.symbol).toUpperCase() !== expectedSymbol) {
|
|
200
|
+
throw new Error(`Expected token symbol ${expectedSymbol}, but metadata resolved ${metadata.symbol}.`);
|
|
201
|
+
}
|
|
202
|
+
await page.click(SUBMIT);
|
|
203
|
+
await waitFor(page, () => visible(page, SUCCESS), 'Custom token success confirmation did not appear.', timeoutMs);
|
|
204
|
+
await page.click(BACK);
|
|
205
|
+
await waitFor(page, () => visible(page, OPTIONS), 'Token list did not reopen.', timeoutMs);
|
|
206
|
+
if (await visible(page, LOW_VALUE)) {
|
|
207
|
+
const expanded = await page.evaluate(`document.querySelector(${JSON.stringify(LOW_VALUE)})?.getAttribute('aria-expanded') === 'true'`);
|
|
208
|
+
if (!expanded) await page.click(LOW_VALUE);
|
|
209
|
+
}
|
|
210
|
+
const token = await waitFor(
|
|
211
|
+
page,
|
|
212
|
+
() => homeToken(page, expectedSymbol),
|
|
213
|
+
`${expectedSymbol} did not appear in the token list after import.`,
|
|
214
|
+
timeoutMs,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
action: input.action,
|
|
219
|
+
network: selectedNetwork.label || network,
|
|
220
|
+
chainId,
|
|
221
|
+
address,
|
|
222
|
+
metadata,
|
|
223
|
+
metadataSource,
|
|
224
|
+
token,
|
|
225
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
226
|
+
};
|
|
227
|
+
}));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { selectors, tokenRow, visible, waitFor } from './send.mjs';
|
|
3
|
+
|
|
4
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
5
|
+
const symbol = String(input.node?.symbol || '').trim().toUpperCase();
|
|
6
|
+
const recipient = String(input.node?.recipient || '').trim();
|
|
7
|
+
const amount = input.node?.amount == null ? null : String(input.node.amount).trim();
|
|
8
|
+
const useMax = input.node?.max === true;
|
|
9
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
10
|
+
if (!symbol || !/^0x[0-9a-f]{40}$/iu.test(recipient) || (useMax === (amount !== null))) {
|
|
11
|
+
throw new Error('metamask.assets.prepare_send requires symbol, EVM recipient, and exactly one of amount or max=true.');
|
|
12
|
+
}
|
|
13
|
+
const token = await waitFor(page, () => tokenRow(page, symbol), `${symbol} was not visible for send.`, timeoutMs);
|
|
14
|
+
await page.click(token.selector);
|
|
15
|
+
await waitFor(page, async () => {
|
|
16
|
+
const route = await page.evaluate('location.hash');
|
|
17
|
+
return /^#\/(?:asset|token)\//u.test(route) ? route : null;
|
|
18
|
+
}, `${symbol} asset details did not open.`, timeoutMs);
|
|
19
|
+
const sendControl = await waitFor(page, async () => {
|
|
20
|
+
for (const selector of [selectors.nativeSend, selectors.send]) {
|
|
21
|
+
if (await visible(page, selector)) return selector;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}, 'Asset Send did not become visible.', timeoutMs);
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
26
|
+
for (let attempt = 0; attempt < 3 && !(await visible(page, selectors.recipient)); attempt += 1) {
|
|
27
|
+
await page.click(sendControl);
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
29
|
+
}
|
|
30
|
+
await waitFor(page, () => visible(page, selectors.recipient), 'Send recipient input did not become visible.', timeoutMs);
|
|
31
|
+
await page.setInput(selectors.recipient, recipient);
|
|
32
|
+
if (useMax) {
|
|
33
|
+
await page.clickText('Max');
|
|
34
|
+
} else {
|
|
35
|
+
await page.setInput(selectors.amount, amount);
|
|
36
|
+
}
|
|
37
|
+
const prepared = await waitFor(page, () => page.evaluate(`(() => {
|
|
38
|
+
const button = document.querySelector(${JSON.stringify(selectors.continue)});
|
|
39
|
+
const amount = document.querySelector(${JSON.stringify(selectors.amount)})?.value || '';
|
|
40
|
+
const balance = document.querySelector('[data-testid="send-amount-balance"]')?.innerText || '';
|
|
41
|
+
return button && !button.disabled ? { amount, balance } : null;
|
|
42
|
+
})()`), 'Prepared send did not enable Continue.', timeoutMs);
|
|
43
|
+
return {
|
|
44
|
+
action: input.action,
|
|
45
|
+
symbol,
|
|
46
|
+
recipient,
|
|
47
|
+
requestedAmount: amount,
|
|
48
|
+
max: useMax,
|
|
49
|
+
tokenBefore: token,
|
|
50
|
+
prepared,
|
|
51
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
52
|
+
};
|
|
53
|
+
}));
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
|
|
3
|
+
function clean(value) {
|
|
4
|
+
if (typeof value !== 'string') return null;
|
|
5
|
+
const normalized = value.replace(/\s+/gu, ' ').trim();
|
|
6
|
+
return normalized ? normalized.slice(0, 500) : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
10
|
+
const expectedSymbol = clean(input.node?.expected_symbol)?.toUpperCase() ?? null;
|
|
11
|
+
const requireChart = input.node?.require_chart === true;
|
|
12
|
+
const requireMarketCap = input.node?.require_market_cap === true;
|
|
13
|
+
const raw = await page.evaluate(`(() => {
|
|
14
|
+
const text = (selector) => {
|
|
15
|
+
const value = document.querySelector(selector)?.innerText;
|
|
16
|
+
return typeof value === 'string' ? value.replace(/\\s+/gu, ' ').trim().slice(0, 500) : null;
|
|
17
|
+
};
|
|
18
|
+
const body = String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim();
|
|
19
|
+
const name = text('[data-testid="asset-name"]');
|
|
20
|
+
return {
|
|
21
|
+
route: String(location.hash),
|
|
22
|
+
name,
|
|
23
|
+
symbol: name?.match(/\\b[A-Za-z][A-Za-z0-9]{1,11}\\b/u)?.[0]?.toUpperCase() || null,
|
|
24
|
+
network: text('[data-testid="asset-network"]'),
|
|
25
|
+
marketCap: text('[data-testid="asset-market-cap"]'),
|
|
26
|
+
chartRendered: Boolean(document.querySelector('[data-testid="asset-price-chart"] canvas')),
|
|
27
|
+
chartEmpty: Boolean(document.querySelector('[data-testid="asset-chart-empty-state"]')),
|
|
28
|
+
malformed: /(?:\\bNaN\\b|\\bundefined\\b|\\bnull\\b)/u.test(body),
|
|
29
|
+
hasActivity: /\\bYour activity\\b/u.test(body),
|
|
30
|
+
};
|
|
31
|
+
})()`);
|
|
32
|
+
|
|
33
|
+
if (!raw.route.startsWith('#/asset/')) {
|
|
34
|
+
throw new Error(`Expected an asset details route, but observed ${raw.route || 'no route'}.`);
|
|
35
|
+
}
|
|
36
|
+
if (expectedSymbol && raw.symbol !== expectedSymbol) {
|
|
37
|
+
throw new Error(`Expected ${expectedSymbol} asset details, but observed ${raw.symbol || 'no symbol'}.`);
|
|
38
|
+
}
|
|
39
|
+
if (requireChart && (!raw.chartRendered || raw.chartEmpty)) {
|
|
40
|
+
throw new Error('Expected a rendered asset price chart without an empty state.');
|
|
41
|
+
}
|
|
42
|
+
if (requireMarketCap && !raw.marketCap) {
|
|
43
|
+
throw new Error('Expected a formatted asset market cap.');
|
|
44
|
+
}
|
|
45
|
+
if (raw.malformed) {
|
|
46
|
+
throw new Error('Asset details contain a malformed visible value.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
action: input.action,
|
|
51
|
+
source: 'visible-ui',
|
|
52
|
+
platform: 'extension',
|
|
53
|
+
surface: 'asset-details',
|
|
54
|
+
...raw,
|
|
55
|
+
proofPath: 'visible-dom-accessibility',
|
|
56
|
+
};
|
|
57
|
+
}));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import {
|
|
3
|
+
normalizeVisibleAssetsState,
|
|
4
|
+
resolveVisibleAssetsOptions,
|
|
5
|
+
} from '../../shared/assets/visible-state.mjs';
|
|
6
|
+
|
|
7
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
8
|
+
const options = resolveVisibleAssetsOptions(input.node);
|
|
9
|
+
const raw = await page.evaluate(`(() => {
|
|
10
|
+
const maximum = ${JSON.stringify(options.maxItems)};
|
|
11
|
+
const rows = [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')]
|
|
12
|
+
.filter((element) => {
|
|
13
|
+
const style = getComputedStyle(element);
|
|
14
|
+
const rect = element.getBoundingClientRect();
|
|
15
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
16
|
+
})
|
|
17
|
+
.slice(0, maximum)
|
|
18
|
+
.map((element) => {
|
|
19
|
+
const change = element.querySelector('[data-testid^="token-increase-decrease-percentage-"]');
|
|
20
|
+
const changeClass = String(change?.className || '');
|
|
21
|
+
const priceChangeTone = changeClass.includes('color-error')
|
|
22
|
+
? 'negative'
|
|
23
|
+
: changeClass.includes('color-success')
|
|
24
|
+
? 'positive'
|
|
25
|
+
: changeClass.includes('color-text-default')
|
|
26
|
+
? 'neutral'
|
|
27
|
+
: null;
|
|
28
|
+
return {
|
|
29
|
+
testId: element.getAttribute('data-testid'),
|
|
30
|
+
label: String(element.getAttribute('aria-label') || element.innerText || '').replace(/\\s+/gu, ' ').trim(),
|
|
31
|
+
name: element.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText,
|
|
32
|
+
fiat: element.querySelector('[data-testid="multichain-token-list-item-secondary-value"]')?.innerText,
|
|
33
|
+
amount: element.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText,
|
|
34
|
+
priceChange: change?.innerText,
|
|
35
|
+
priceChangeTone,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
route: location.hash,
|
|
40
|
+
title: document.title,
|
|
41
|
+
items: rows,
|
|
42
|
+
truncated: document.querySelectorAll('[data-testid="multichain-token-list-button"]').length > rows.length,
|
|
43
|
+
};
|
|
44
|
+
})()`);
|
|
45
|
+
return {
|
|
46
|
+
action: input.action,
|
|
47
|
+
...normalizeVisibleAssetsState(raw, options, 'extension'),
|
|
48
|
+
proofPath: 'visible-dom-accessibility',
|
|
49
|
+
};
|
|
50
|
+
}));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { bodyState, selectors, visible, waitFor } from './send.mjs';
|
|
3
|
+
|
|
4
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
5
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
6
|
+
await waitFor(page, () => visible(page, selectors.continue), 'Send Continue was not visible.', timeoutMs);
|
|
7
|
+
await page.click(selectors.continue);
|
|
8
|
+
const deadline = Date.now() + timeoutMs;
|
|
9
|
+
while (Date.now() < deadline && !(await visible(page, selectors.confirm))) {
|
|
10
|
+
if (await visible(page, selectors.alert)) await page.click(selectors.alert);
|
|
11
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
12
|
+
}
|
|
13
|
+
await waitFor(page, () => visible(page, selectors.confirm), 'Send confirmation did not open.', timeoutMs);
|
|
14
|
+
const state = await bodyState(page);
|
|
15
|
+
if (!/\b(network fee|gas)\b/iu.test(state.text)) {
|
|
16
|
+
throw new Error('Send confirmation did not show a network fee.');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
action: input.action,
|
|
20
|
+
...state,
|
|
21
|
+
proofPath: 'visible-dom-accessibility',
|
|
22
|
+
};
|
|
23
|
+
}));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { openHome } from '../ui/navigate.mjs';
|
|
3
|
+
import { visible, waitFor } from './send.mjs';
|
|
4
|
+
|
|
5
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
6
|
+
const scope = String(input.node?.scope || 'all').trim().toLowerCase();
|
|
7
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
8
|
+
if (scope !== 'all') {
|
|
9
|
+
throw new Error('metamask.assets.select_network_scope currently requires scope=all.');
|
|
10
|
+
}
|
|
11
|
+
await openHome(page, timeoutMs);
|
|
12
|
+
const filter = dataTestId('sort-by-networks');
|
|
13
|
+
const all = dataTestId('home-network-filter-all-default');
|
|
14
|
+
const selected = await waitFor(page, async () => {
|
|
15
|
+
const label = await page.evaluate(`String(document.querySelector(${JSON.stringify(filter)})?.innerText || '')`);
|
|
16
|
+
if (/All (?:default )?networks/iu.test(label)) return label;
|
|
17
|
+
if (await visible(page, all)) {
|
|
18
|
+
await page.click(all);
|
|
19
|
+
} else if (await visible(page, filter)) {
|
|
20
|
+
await page.click(filter);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}, 'All networks did not become selected.', timeoutMs);
|
|
24
|
+
return {
|
|
25
|
+
action: input.action,
|
|
26
|
+
scope,
|
|
27
|
+
selected,
|
|
28
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
29
|
+
};
|
|
30
|
+
}));
|