@deeeed/metamask-harness 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +27 -17
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/assets/open_details.mjs +30 -0
  20. package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
  21. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  22. package/library/actions/extension/networks/add_custom.mjs +4 -0
  23. package/library/actions/extension/networks/custom_network.mjs +48 -1
  24. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  25. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  26. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  27. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  28. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  29. package/library/actions/extension/perps/perps.mjs +1029 -363
  30. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  31. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  32. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  33. package/library/actions/extension/perps/search_markets.mjs +32 -0
  34. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  35. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  36. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  37. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  38. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  39. package/library/actions/extension/platform/cdp.mjs +88 -9
  40. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  41. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  42. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  43. package/library/actions/extension/ui/navigate.mjs +45 -5
  44. package/library/actions/extension/wallet/import.mjs +70 -29
  45. package/library/actions/extension/wallet/lock.mjs +62 -5
  46. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  47. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  48. package/library/actions/mobile/assets/open_details.mjs +43 -0
  49. package/library/actions/mobile/assets/read_details.mjs +96 -0
  50. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  51. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  52. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  53. package/library/actions/mobile/networks/network-management.mjs +132 -0
  54. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  55. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  56. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  57. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  58. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  59. package/library/actions/mobile/platform/native-session.mjs +59 -0
  60. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  61. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  62. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  63. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  64. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  65. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  66. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  67. package/library/actions/mobile/ui/native-navigation.mjs +120 -23
  68. package/library/actions/mobile/ui/navigate.mjs +13 -2
  69. package/library/actions/mobile/wallet/import.mjs +17 -10
  70. package/library/actions/mobile/wallet/lock.mjs +14 -0
  71. package/library/actions/mobile/wallet/native-ui.mjs +88 -15
  72. package/library/actions/shared/perps/visible-state.mjs +33 -11
  73. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  74. package/library/manifests/extension.action-manifest.json +1707 -1241
  75. package/library/manifests/mobile.action-manifest.json +308 -1
  76. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  77. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  78. package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
  79. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  80. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  81. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  82. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  83. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  84. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  85. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  86. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  87. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  88. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  89. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  90. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  91. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  92. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  93. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  94. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  95. package/library/recipes/wallet/import.recipe.json +4 -14
  96. package/package.json +1 -1
  97. package/library/actions/extension/assets/finish_send.mjs +0 -128
  98. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  99. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  100. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  101. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  102. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  103. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  104. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -7,14 +7,14 @@
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
9
  "source_symbol": { "type": "string", "default": "ETH" },
10
- "destination_symbol": { "type": "string", "default": "WETH" },
11
- "source_chain": { "type": "string", "default": "eip155:1" },
12
- "destination_chain": { "type": "string", "default": "eip155:1" },
10
+ "destination_symbol": { "type": "string", "default": "USDC" },
11
+ "source_chain": { "type": "string", "default": "eip155:42161" },
12
+ "destination_chain": { "type": "string", "default": "eip155:42161" },
13
13
  "source_query": { "type": "string", "default": "ETH" },
14
- "destination_query": { "type": "string", "default": "WETH" },
14
+ "destination_query": { "type": "string", "default": "USDC" },
15
15
  "source_asset_id": { "type": "string", "default": "" },
16
16
  "destination_asset_id": { "type": "string", "default": "" },
17
- "amount": { "type": "number", "minimum": 0.00000001, "default": 0.0001 },
17
+ "amount": { "type": "number", "minimum": 0.00000001, "default": 0.002 },
18
18
  "slippage": { "type": "number", "minimum": 0.01, "default": 3 },
19
19
  "timeout_ms": { "type": "integer", "minimum": 1000, "default": 60000 }
20
20
  }
@@ -67,14 +67,6 @@
67
67
  "require_quote": true,
68
68
  "expected_slippage": "{{params.slippage}}",
69
69
  "intent": "Prove the live quote retains the custom slippage value",
70
- "next": "wait-for-quote-display"
71
- },
72
- "wait-for-quote-display": {
73
- "action": "ui.wait_for",
74
- "test_id": "minimum-received",
75
- "expected": "visible",
76
- "timeout_ms": "{{params.timeout_ms}}",
77
- "intent": "Confirm the refreshed quote is visible",
78
70
  "next": "capture-quote"
79
71
  },
80
72
  "capture-quote": {
@@ -56,6 +56,7 @@
56
56
  "destination_symbol": "{{params.destination_symbol}}",
57
57
  "require_amount_inputs": true,
58
58
  "require_settings": true,
59
+ "require_quote": true,
59
60
  "intent": "Prove the selected pair and quoted destination amount remain visible",
60
61
  "next": "capture-quote"
61
62
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
3
3
  "title": "Import or reuse a MetaMask wallet",
4
- "description": "Make a Mobile or Extension wallet ready by reusing its fixture-backed profile or importing the primary mnemonic through visible onboarding UI.",
4
+ "description": "Make a Mobile or Extension wallet ready through its selected setup method; Extension method=ui uses visible onboarding or proves the exact selected account in the existing visible session.",
5
5
  "paramsSchema": {
6
6
  "type": "object",
7
7
  "additionalProperties": false,
@@ -10,7 +10,7 @@
10
10
  "type": "string",
11
11
  "enum": ["auto", "profile", "ui"],
12
12
  "default": "auto",
13
- "description": "Choose automatically from visible wallet state, require an existing profile, or require real-user onboarding UI."
13
+ "description": "Choose automatically from visible wallet state, require an existing profile, or require visible UI. Extension ui imports a fresh wallet or proves the exact account in an existing session; Mobile ui requires fresh onboarding."
14
14
  },
15
15
  "credential_source": {
16
16
  "type": "string",
@@ -66,13 +66,8 @@
66
66
  }
67
67
  },
68
68
  "workflow": {
69
- "entry": "fixture-status",
69
+ "entry": "import",
70
70
  "nodes": {
71
- "fixture-status": {
72
- "action": "metamask.wallet.fixture_status",
73
- "intent": "Record whether a canonical fixture is available without exposing its secrets",
74
- "next": "import"
75
- },
76
71
  "import": {
77
72
  "action": "metamask.wallet.import",
78
73
  "method": "{{params.method}}",
@@ -85,12 +80,7 @@
85
80
  "skip_wallet_tour": "{{params.skip_wallet_tour}}",
86
81
  "interests": "{{params.interests}}",
87
82
  "timeout_ms": "{{params.timeout_ms}}",
88
- "intent": "Reuse the fixture-backed wallet or import it through the visible onboarding flow",
89
- "next": "state"
90
- },
91
- "state": {
92
- "action": "metamask.wallet.read_state",
93
- "intent": "Prove the wallet has a selected account after setup",
83
+ "intent": "Make the wallet ready and prove the selected account through the requested setup method",
94
84
  "next": "done"
95
85
  },
96
86
  "done": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -1,128 +0,0 @@
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
- }));
@@ -1,363 +0,0 @@
1
- import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
- import { bodyState, tokenRow, transactionState, visible, waitFor } from '../assets/send.mjs';
3
-
4
- const selectors = {
5
- cta: dataTestId('bridge-cta-button'),
6
- warningProceed: `${dataTestId('bridge-alert-modal-proceed-button')}:not([disabled])`,
7
- confirm: dataTestId('confirm-sign-and-send-transaction-confirm-snap-footer-button'),
8
- close: 'button[aria-label="Close"]',
9
- activity: dataTestId('bottom-nav-activity'),
10
- home: dataTestId('bottom-nav-home'),
11
- swap: dataTestId('bottom-nav-swaps'),
12
- };
13
-
14
- async function pause(timeoutMs = 200) {
15
- await new Promise((resolve) => setTimeout(resolve, timeoutMs));
16
- }
17
-
18
- async function activatePoint(page, point) {
19
- await page.session.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
20
- await page.session.call('Input.dispatchMouseEvent', {
21
- type: 'mousePressed',
22
- x: point.x,
23
- y: point.y,
24
- button: 'left',
25
- clickCount: 1,
26
- });
27
- await page.session.call('Input.dispatchMouseEvent', {
28
- type: 'mouseReleased',
29
- x: point.x,
30
- y: point.y,
31
- button: 'left',
32
- clickCount: 1,
33
- });
34
- }
35
-
36
- async function activateSelector(page, selector) {
37
- const point = await page.evaluate(`(() => {
38
- const element = document.querySelector(${JSON.stringify(selector)});
39
- if (!element) return null;
40
- const style = getComputedStyle(element);
41
- const rect = element.getBoundingClientRect();
42
- if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
43
- return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
44
- })()`);
45
- if (!point) return false;
46
- await activatePoint(page, point);
47
- return true;
48
- }
49
-
50
- function isActivityRoute(route) {
51
- return route.includes('/activity') || /[?&]tab=activity(?:&|$)/u.test(route);
52
- }
53
-
54
- function transactionIdentifier(value) {
55
- const identifier = String(value);
56
- return /^0x/iu.test(identifier) ? identifier.toLowerCase() : identifier;
57
- }
58
-
59
- async function openActivity(page, timeoutMs) {
60
- const deadline = Date.now() + timeoutMs;
61
- let lastActivationAt = 0;
62
- let lastResetAt = 0;
63
- while (Date.now() < deadline) {
64
- const state = await bodyState(page);
65
- if (isActivityRoute(state.route) && await visible(page, dataTestId('activity-list-item'))) return state;
66
- if (isActivityRoute(state.route) && await visible(page, selectors.swap) && Date.now() - lastResetAt >= 1000) {
67
- await activateSelector(page, selectors.swap);
68
- lastResetAt = Date.now();
69
- await pause();
70
- continue;
71
- }
72
- if (await visible(page, selectors.activity) && Date.now() - lastActivationAt >= 1000) {
73
- await activateSelector(page, selectors.activity);
74
- lastActivationAt = Date.now();
75
- }
76
- await pause();
77
- }
78
- throw new Error('Activity did not open after swap.');
79
- }
80
-
81
- async function quoteState(page) {
82
- return page.evaluate(`(() => {
83
- const value = (testId) => {
84
- const root = document.querySelector('[data-testid="' + testId + '"]');
85
- const input = root?.matches('input, textarea') ? root : root?.querySelector('input, textarea');
86
- return String(input?.value ?? '').trim();
87
- };
88
- const label = (testId) => String(document.querySelector('[data-testid="' + testId + '"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
89
- const cta = document.querySelector('[data-testid="bridge-cta-button"]');
90
- return {
91
- route: location.hash,
92
- sourceSymbol: label('bridge-source-button'),
93
- destinationSymbol: label('bridge-destination-button'),
94
- sourceAmount: value('from-amount'),
95
- destinationAmount: value('to-amount'),
96
- cta: cta ? String(cta.innerText || '').replace(/\\s+/gu, ' ').trim() : null,
97
- ctaEnabled: Boolean(cta && !cta.disabled),
98
- loading: Boolean(document.querySelector('[data-testid="multichain-bridge-quote-card-loading"]')),
99
- warning: String(document.body?.innerText || '').match(/(?:might be reverted|may fail|likely to fail|insufficient funds|trade route isn't available)[^.]{0,300}/iu)?.[0] || null,
100
- };
101
- })()`);
102
- }
103
-
104
- async function submittableQuote(page, timeoutMs) {
105
- const deadline = Date.now() + timeoutMs;
106
- while (Date.now() < deadline) {
107
- const quote = await quoteState(page);
108
- if (/^(?:Get new quote|Refresh quote)$/iu.test(String(quote.cta)) && quote.ctaEnabled) {
109
- await page.click(selectors.cta);
110
- await pause(500);
111
- continue;
112
- }
113
- if (/^(?:Swap|Bridge)$/iu.test(String(quote.cta)) && quote.ctaEnabled && !quote.loading) return quote;
114
- await pause();
115
- }
116
- throw new Error('Swap did not expose a fresh submittable quote.');
117
- }
118
-
119
- async function activityRow(page, sourceSymbol, destinationSymbol, occurrence = 0) {
120
- return page.evaluate(`(() => {
121
- const source = ${JSON.stringify(sourceSymbol)};
122
- const destination = ${JSON.stringify(destinationSymbol)};
123
- const occurrence = ${JSON.stringify(occurrence)};
124
- let matched = 0;
125
- for (const row of document.querySelectorAll('[data-testid="activity-list-item"]')) {
126
- const label = String(row.innerText || row.textContent || '').replace(/\\s+/gu, ' ').trim();
127
- const upper = label.toUpperCase();
128
- const pair = upper.includes(source + ' → ' + destination)
129
- || upper.includes(source + ' -> ' + destination);
130
- const bridgeSource = upper.includes('BRIDGED ' + source);
131
- if (!pair && !bridgeSource) continue;
132
- const style = getComputedStyle(row);
133
- const rect = row.getBoundingClientRect();
134
- if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
135
- if (matched < occurrence) {
136
- matched += 1;
137
- continue;
138
- }
139
- return {
140
- label: label.slice(0, 1000),
141
- status: String(row.getAttribute('data-tx-status') || ''),
142
- x: rect.left + rect.width / 2,
143
- y: rect.top + rect.height / 2,
144
- };
145
- }
146
- return null;
147
- })()`);
148
- }
149
-
150
- async function activateActivityRow(page, row) {
151
- await activatePoint(page, row);
152
- }
153
-
154
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
155
- const sourceSymbol = String(input.node?.source_symbol || '').trim().toUpperCase();
156
- const destinationSymbol = String(input.node?.destination_symbol || '').trim().toUpperCase();
157
- const requestedAmount = String(input.node?.amount || '').trim();
158
- const resumeSubmitted = input.node?.resume_submitted === true;
159
- const resumedHash = String(input.node?.transaction_hash || '').trim();
160
- const activityOccurrence = Number(input.node?.activity_occurrence ?? 0);
161
- const requireDestinationBalance = input.node?.require_destination_balance !== false;
162
- const timeoutMs = Number(input.node?.timeout_ms ?? 300_000);
163
- if (!sourceSymbol || !destinationSymbol || !requestedAmount || !Number.isInteger(activityOccurrence) || activityOccurrence < 0) {
164
- throw new Error('metamask.swap_bridge.submit_transaction requires source_symbol, destination_symbol, and amount.');
165
- }
166
- if (!resumeSubmitted && input.node?.confirm_live !== true) {
167
- throw new Error('metamask.swap_bridge.submit_transaction requires confirm_live=true.');
168
- }
169
- if (resumeSubmitted && !resumedHash) {
170
- throw new Error('metamask.swap_bridge.submit_transaction resume_submitted=true requires transaction_hash.');
171
- }
172
-
173
- let submitted = resumeSubmitted;
174
- let confirmed = resumeSubmitted;
175
- let amount = requestedAmount;
176
- let beforeTransactionIdentifiers = new Set();
177
- if (!resumeSubmitted) {
178
- let quote = await submittableQuote(page, Math.min(timeoutMs, 60_000));
179
- if (requestedAmount === 'visible') amount = quote.sourceAmount;
180
- if (!quote.sourceSymbol.toUpperCase().includes(sourceSymbol)
181
- || !quote.destinationSymbol.toUpperCase().includes(destinationSymbol)
182
- || quote.sourceAmount !== amount
183
- || !(Number(quote.destinationAmount) > 0)
184
- || !quote.ctaEnabled
185
- || quote.loading
186
- || quote.warning) {
187
- throw new Error(`Swap quote is not safe to submit: ${JSON.stringify(quote)}.`);
188
- }
189
- await pause(2000);
190
- quote = await submittableQuote(page, Math.min(timeoutMs, 60_000));
191
- if (!quote.sourceSymbol.toUpperCase().includes(sourceSymbol)
192
- || !quote.destinationSymbol.toUpperCase().includes(destinationSymbol)
193
- || quote.sourceAmount !== amount
194
- || !(Number(quote.destinationAmount) > 0)
195
- || !quote.ctaEnabled
196
- || quote.loading
197
- || quote.warning) {
198
- throw new Error(`Swap quote changed before submission: ${JSON.stringify(quote)}.`);
199
- }
200
- const beforeTransactions = await transactionState(page);
201
- if (!beforeTransactions.available) {
202
- throw new Error('Transaction state is unavailable; refusing to submit without proof binding.');
203
- }
204
- beforeTransactionIdentifiers = new Set(beforeTransactions.transactions.flatMap((transaction) => [
205
- transaction.id,
206
- transaction.hash,
207
- ].filter(Boolean).map(transactionIdentifier)));
208
- await page.click(selectors.cta);
209
- const submissionDeadline = Date.now() + Math.min(timeoutMs, 120_000);
210
- while (Date.now() < submissionDeadline) {
211
- if (await visible(page, selectors.warningProceed)) {
212
- await page.click(selectors.warningProceed);
213
- await pause();
214
- continue;
215
- }
216
- if (await visible(page, selectors.confirm)) {
217
- await page.click(selectors.confirm);
218
- confirmed = true;
219
- submitted = true;
220
- await pause();
221
- continue;
222
- }
223
- const state = await bodyState(page);
224
- if (state.route !== '#/cross-chain/swaps/prepare-bridge-page'
225
- && !state.route.includes('/confirmation')) {
226
- submitted = true;
227
- }
228
- if (submitted && (state.route === '' || state.route === '#/' || isActivityRoute(state.route) || /Processing|Transaction complete|submitted/iu.test(state.text))) {
229
- break;
230
- }
231
- await pause();
232
- }
233
- if (!submitted) {
234
- throw new Error('Swap submission did not reach a signed or submitted state.');
235
- }
236
- }
237
-
238
- let activity = null;
239
- let details = await bodyState(page);
240
- const resumedFromDetails = details.route.includes('/tx/')
241
- && /\bConfirmed\b/u.test(details.text)
242
- && /\bYou sent\b/u.test(details.text)
243
- && /\bYou received\b/u.test(details.text)
244
- && details.text.toUpperCase().includes(sourceSymbol)
245
- && details.text.toUpperCase().includes(destinationSymbol);
246
- if (!resumedFromDetails) {
247
- await waitFor(page, async () => {
248
- const state = await bodyState(page);
249
- if (isActivityRoute(state.route) || await visible(page, selectors.activity)) return state;
250
- if (await visible(page, selectors.close)) await page.click(selectors.close);
251
- return null;
252
- }, 'Swap did not expose Activity after submission.', timeoutMs);
253
- await openActivity(page, timeoutMs);
254
-
255
- let lastActivityRefresh = 0;
256
- const activityTarget = await waitFor(page, async () => {
257
- const row = await activityRow(page, sourceSymbol, destinationSymbol, activityOccurrence);
258
- if (row?.status === 'failed') throw new Error(`Swap Activity failed: ${row.label}.`);
259
- if (row?.status === 'confirmed') return row;
260
- if (Date.now() - lastActivityRefresh >= 5000 && await visible(page, selectors.home)) {
261
- await page.click(selectors.home);
262
- await pause(500);
263
- await openActivity(page, Math.min(10_000, Math.max(1, timeoutMs)));
264
- lastActivityRefresh = Date.now();
265
- }
266
- return null;
267
- }, `Swap ${sourceSymbol} to ${destinationSymbol} did not reach Confirmed in Activity.`, timeoutMs);
268
- activity = { label: activityTarget.label, status: activityTarget.status };
269
- confirmed = true;
270
- let lastDetailsActivationAt = 0;
271
- details = await waitFor(page, async () => {
272
- const current = await bodyState(page);
273
- if (current.route.includes('/tx/') && /\bConfirmed\b/u.test(current.text)) return current;
274
- if (Date.now() - lastDetailsActivationAt >= 1000) {
275
- await activateActivityRow(page, activityTarget);
276
- lastDetailsActivationAt = Date.now();
277
- }
278
- return null;
279
- }, 'Confirmed swap transaction details did not open.', timeoutMs);
280
- }
281
- const explorerUrl = await page.evaluate(`document.querySelector('[data-testid="transaction-details-block-explorer"]')?.getAttribute('data-explorer-url') || null`);
282
- const routeIdentifier = details.route.match(/\/([^/?#]+)(?:\?|$)/u)?.[1] ?? null;
283
- const transactionHash = details.route.match(/\/(0x[0-9a-f]{64})(?:\?|$)/iu)?.[1]
284
- ?? details.text.match(/\b0x[0-9a-f]{64}\b/iu)?.[0]
285
- ?? String(explorerUrl || '').match(/\/(0x[0-9a-f]{64})(?:[/?#]|$)/iu)?.[1]
286
- ?? (/^[1-9A-HJ-NP-Za-km-z]{64,100}$/u.test(String(routeIdentifier)) ? routeIdentifier : null)
287
- ?? String(explorerUrl || '').match(/\/(?:tx|transaction)\/([1-9A-HJ-NP-Za-km-z]{64,100})(?:[/?#]|$)/u)?.[1]
288
- ?? null;
289
- if (!transactionHash) throw new Error('Confirmed swap details did not expose a full transaction hash.');
290
- if (resumeSubmitted && transactionIdentifier(transactionHash) !== transactionIdentifier(resumedHash)) {
291
- throw new Error(`Confirmed swap details opened ${transactionHash}, expected ${resumedHash}.`);
292
- }
293
- if (!resumeSubmitted) {
294
- const confirmedIdentifier = transactionIdentifier(transactionHash);
295
- if (beforeTransactionIdentifiers.has(confirmedIdentifier)) {
296
- throw new Error(`Confirmed swap details opened pre-existing transaction ${transactionHash}.`);
297
- }
298
- const afterTransactions = await transactionState(page);
299
- const submittedTransaction = afterTransactions.transactions.find((transaction) =>
300
- transactionIdentifier(transaction.hash) === confirmedIdentifier &&
301
- !beforeTransactionIdentifiers.has(transactionIdentifier(transaction.id)));
302
- if (!submittedTransaction) {
303
- throw new Error(`Confirmed swap ${transactionHash} is not bound to a newly created transaction record.`);
304
- }
305
- }
306
- const detailText = details.text.toUpperCase();
307
- const sentSource = new RegExp('YOU SENT[\\s\\S]{0,120}\\b' + sourceSymbol + '\\b', 'u');
308
- const receivedDestination = new RegExp('YOU RECEIVED[\\s\\S]{0,120}\\b' + destinationSymbol + '\\b', 'u');
309
- if (!sentSource.test(detailText) || !receivedDestination.test(detailText)) {
310
- throw new Error(`Confirmed swap details do not prove ${sourceSymbol} to ${destinationSymbol}.`);
311
- }
312
- const back = dataTestId('transaction-details-back-button');
313
- let lastBackActivationAt = 0;
314
- await waitFor(page, async () => {
315
- const current = await bodyState(page);
316
- if (isActivityRoute(current.route)) return current;
317
- if (await visible(page, back) && Date.now() - lastBackActivationAt >= 1000) {
318
- await page.click(back);
319
- lastBackActivationAt = Date.now();
320
- }
321
- return null;
322
- }, 'Swap details did not return to Activity.', timeoutMs);
323
- await waitFor(page, async () => await visible(page, selectors.home), 'Home navigation did not return after swap details.', timeoutMs);
324
- let lastHomeActivationAt = 0;
325
- await waitFor(page, async () => {
326
- const current = await bodyState(page);
327
- if (current.route === '' || current.route === '#/' || current.route.startsWith('#/?')) return current;
328
- if (await visible(page, selectors.home) && Date.now() - lastHomeActivationAt >= 1000) {
329
- await page.click(selectors.home);
330
- lastHomeActivationAt = Date.now();
331
- }
332
- return null;
333
- }, 'Home did not open after the confirmed swap.', timeoutMs);
334
- const destinationAfter = requireDestinationBalance
335
- ? await waitFor(
336
- page,
337
- async () => {
338
- const row = await tokenRow(page, destinationSymbol);
339
- const numeric = Number(String(row?.amount || '').replace(/[^0-9.eE+-]/gu, ''));
340
- return row && Number.isFinite(numeric) && numeric > 0 ? row : null;
341
- },
342
- `${destinationSymbol} did not reach a positive Home balance after the transaction.`,
343
- timeoutMs,
344
- )
345
- : null;
346
- return {
347
- action: input.action,
348
- sourceSymbol,
349
- destinationSymbol,
350
- amount,
351
- submitted,
352
- confirmed,
353
- activity,
354
- details,
355
- destinationAfter,
356
- explorerUrl,
357
- transactionHash,
358
- resumed: resumeSubmitted,
359
- activityOccurrence,
360
- destinationBalanceRequired: requireDestinationBalance,
361
- proofPath: 'trusted-pointer-visible-ui',
362
- };
363
- }));