@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +138 -0
  3. package/adapters/extension/inject.mjs +2 -0
  4. package/adapters/extension/launch-browser.cjs +24 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +17 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +164 -20
  15. package/dist/adapters/extension/runtime.js +169 -3
  16. package/dist/adapters/mobile/provision.js +38 -3
  17. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  18. package/dist/adapters/mobile/release-artifact.js +295 -0
  19. package/dist/adapters.js +78 -6
  20. package/dist/app-lifecycle.js +4 -3
  21. package/dist/command-contract.js +16 -0
  22. package/dist/commands/call.js +6 -2
  23. package/dist/commands/launch/extension.js +74 -9
  24. package/dist/commands/launch/index.js +3 -0
  25. package/dist/commands/launch/mobile.js +2 -0
  26. package/dist/commands/provision.js +2 -0
  27. package/dist/commands/run-engine.js +109 -14
  28. package/dist/commands/run.js +4 -2
  29. package/dist/commands/runtime-launch.js +178 -10
  30. package/dist/heal-bounds.js +1 -1
  31. package/dist/live-adapter-contract.js +3 -1
  32. package/dist/metamask-action-validation.js +47 -1
  33. package/dist/mm-harness-cli.js +31 -1
  34. package/dist/recipe-security.js +40 -0
  35. package/dist/run-diagnostics.js +1 -1
  36. package/dist/runner.js +3 -0
  37. package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
  38. package/library/actions/extension/assets/finish_send.mjs +128 -0
  39. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  40. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  41. package/library/actions/extension/assets/read_details.mjs +57 -0
  42. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  43. package/library/actions/extension/assets/review_send.mjs +23 -0
  44. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  45. package/library/actions/extension/assets/send.mjs +104 -0
  46. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  47. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  48. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  49. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  50. package/library/actions/extension/networks/add_custom.mjs +123 -0
  51. package/library/actions/extension/networks/custom_network.mjs +103 -0
  52. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  53. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  54. package/library/actions/extension/perps/close_orders.mjs +1 -0
  55. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  56. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  57. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  58. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  59. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  60. package/library/actions/extension/perps/perps.mjs +196 -2
  61. package/library/actions/extension/perps/place_order.mjs +5 -2
  62. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  63. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  64. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  65. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  66. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  67. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  68. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  69. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  70. package/library/actions/extension/platform/cdp.mjs +109 -3
  71. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  72. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  73. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  74. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  75. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  76. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  77. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  78. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  79. package/library/actions/extension/ui/navigate.mjs +264 -8
  80. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  81. package/library/actions/extension/wallet/import.mjs +13 -46
  82. package/library/actions/extension/wallet/lock.mjs +35 -0
  83. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  84. package/library/actions/extension/wallet/select_account.mjs +71 -7
  85. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  86. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  87. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  88. package/library/actions/mobile/platform/observe-ui.mjs +86 -2
  89. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  90. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  91. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  92. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  93. package/library/actions/mobile/ui/native-navigation.mjs +265 -0
  94. package/library/actions/mobile/ui/navigate.mjs +10 -1
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  96. package/library/actions/mobile/wallet/import.mjs +71 -2
  97. package/library/actions/mobile/wallet/native-ui.mjs +512 -0
  98. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  99. package/library/actions/mobile/wallet/reset.mjs +17 -4
  100. package/library/actions/shared/assets/visible-state.mjs +176 -0
  101. package/library/actions/shared/networks/visible-state.mjs +70 -0
  102. package/library/actions/shared/perps/visible-state.mjs +354 -0
  103. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  104. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  105. package/library/actions/shared/ui/locators.mjs +7 -0
  106. package/library/manifests/extension.action-manifest.json +1366 -184
  107. package/library/manifests/mobile.action-manifest.json +235 -1
  108. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  109. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  110. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  111. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  112. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  113. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  114. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  115. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  116. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  117. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  118. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  119. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  120. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  121. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  122. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  123. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  124. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  125. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  126. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  127. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  128. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  129. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  130. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  131. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  132. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  133. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  134. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  135. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  136. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  137. package/library/recipes/wallet/import.recipe.json +20 -1
  138. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  139. package/package.json +6 -5
  140. package/scripts/validate-release-report.mjs +319 -0
@@ -0,0 +1,176 @@
1
+ function boundedInteger(value, fallback, minimum, maximum, name) {
2
+ const resolved = value == null ? fallback : Number(value);
3
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
4
+ throw new Error(
5
+ `metamask.assets.read_visible_state ${name} must be an integer from ${minimum} to ${maximum}.`,
6
+ );
7
+ }
8
+ return resolved;
9
+ }
10
+
11
+ function stringList(value, name, maximum = 50) {
12
+ if (value == null) return [];
13
+ if (!Array.isArray(value)) {
14
+ throw new Error(`metamask.assets.read_visible_state ${name} must be an array.`);
15
+ }
16
+ const values = Array.from(new Set(value.map((entry) => String(entry).trim().toUpperCase()).filter(Boolean)));
17
+ if (values.length > maximum) {
18
+ throw new Error(`metamask.assets.read_visible_state ${name} accepts at most ${maximum} values.`);
19
+ }
20
+ return values;
21
+ }
22
+
23
+ export function resolveVisibleAssetsOptions(node = {}) {
24
+ return {
25
+ maxItems: boundedInteger(node.max_items, 100, 1, 200, 'max_items'),
26
+ minimumAssetCount: boundedInteger(node.minimum_asset_count, 0, 0, 200, 'minimum_asset_count'),
27
+ minimumPriceChangeCount: boundedInteger(
28
+ node.minimum_price_change_count,
29
+ 0,
30
+ 0,
31
+ 200,
32
+ 'minimum_price_change_count',
33
+ ),
34
+ minimumWithoutPriceChangeCount: boundedInteger(
35
+ node.minimum_without_price_change_count,
36
+ 0,
37
+ 0,
38
+ 200,
39
+ 'minimum_without_price_change_count',
40
+ ),
41
+ requireSymbols: stringList(node.require_symbols, 'require_symbols'),
42
+ requireFiat: node.require_fiat === true,
43
+ requirePriceChangeStyling: node.require_price_change_styling === true,
44
+ };
45
+ }
46
+
47
+ function text(value) {
48
+ if (typeof value !== 'string') return undefined;
49
+ const normalized = value.replace(/\s+/gu, ' ').trim();
50
+ return normalized ? normalized.slice(0, 500) : undefined;
51
+ }
52
+
53
+ function firstMatch(value, expression) {
54
+ return text(value)?.match(expression)?.[0];
55
+ }
56
+
57
+ function priceChangeFromLabel(value) {
58
+ const label = text(value);
59
+ if (!label) return undefined;
60
+ for (const match of label.matchAll(/[+-]?\d+(?:\.\d+)?%/gu)) {
61
+ const following = label.slice((match.index ?? 0) + match[0].length, (match.index ?? 0) + match[0].length + 8);
62
+ if (!/^\s*APY\b/iu.test(following)) return match[0];
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ function symbolFromAmount(value) {
68
+ return text(value)?.match(/\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1]?.toUpperCase();
69
+ }
70
+
71
+ function symbolFromTestId(value) {
72
+ const testId = text(value);
73
+ if (!testId?.startsWith('asset-')) return undefined;
74
+ return testId.slice('asset-'.length).trim().toUpperCase() || undefined;
75
+ }
76
+
77
+ function normalizeItem(source) {
78
+ const label = text(source?.label);
79
+ const amount = text(source?.amount) ?? text(label?.split(',').at(-1));
80
+ const fiat = text(source?.fiat) ?? firstMatch(label, /[$€£¥]\s?\d[\d,]*(?:\.\d{2})(?!\d)/u);
81
+ const priceChange = text(source?.priceChange) ?? priceChangeFromLabel(label);
82
+ const symbol = text(source?.symbol)?.toUpperCase() ?? symbolFromTestId(source?.testId) ?? symbolFromAmount(amount);
83
+ return {
84
+ testId: text(source?.testId) ?? null,
85
+ symbol: symbol ?? null,
86
+ name: text(source?.name) ?? text(label?.split(',')[0]) ?? symbol ?? null,
87
+ fiat: fiat ?? null,
88
+ amount: amount ?? null,
89
+ priceChange: priceChange ?? null,
90
+ priceChangeTone: text(source?.priceChangeTone)?.toLowerCase() ?? null,
91
+ ...(label ? { label } : {}),
92
+ };
93
+ }
94
+
95
+ export function normalizeVisibleAssetsState(raw, options, platform) {
96
+ const assets = (Array.isArray(raw.items) ? raw.items : [])
97
+ .slice(0, options.maxItems)
98
+ .map(normalizeItem)
99
+ .filter((item) => item.symbol || item.name);
100
+ const malformed = assets.filter((item) =>
101
+ /(?:\bNaN\b|\bundefined\b|\bnull\b)/u.test(
102
+ [item.label, item.fiat, item.amount, item.priceChange].filter(Boolean).join(' '),
103
+ )
104
+ );
105
+ const state = {
106
+ source: 'visible-ui',
107
+ platform,
108
+ surface: 'token-list',
109
+ ...(text(raw.route) ? { route: text(raw.route) } : {}),
110
+ ...(text(raw.title) ? { title: text(raw.title) } : {}),
111
+ assets,
112
+ observedSymbols: Array.from(new Set(assets.map((item) => item.symbol).filter(Boolean))),
113
+ features: {
114
+ 'token-list': assets.length > 0,
115
+ fiat: assets.some((item) => item.fiat !== null),
116
+ 'price-change': assets.some((item) => item.priceChange !== null),
117
+ 'price-change-styling': assets.some((item) => item.priceChangeTone !== null),
118
+ },
119
+ malformedValueCount: malformed.length,
120
+ truncated: raw.truncated === true || (raw.items?.length ?? 0) > options.maxItems,
121
+ };
122
+ assertVisibleAssetsState(state, options);
123
+ return state;
124
+ }
125
+
126
+ function assertVisibleAssetsState(state, options) {
127
+ if (state.assets.length < options.minimumAssetCount) {
128
+ throw new Error(
129
+ `Expected at least ${options.minimumAssetCount} visible assets, but observed ${state.assets.length}.`,
130
+ );
131
+ }
132
+ const observed = new Set(state.observedSymbols);
133
+ const missing = options.requireSymbols.filter((symbol) => !observed.has(symbol));
134
+ if (missing.length > 0) {
135
+ throw new Error(`Missing required visible asset symbols: ${missing.join(', ')}.`);
136
+ }
137
+ if (options.requireFiat) {
138
+ const missingFiat = state.assets
139
+ .filter((item) => options.requireSymbols.length === 0 || options.requireSymbols.includes(item.symbol))
140
+ .filter((item) => item.fiat === null)
141
+ .map((item) => item.symbol ?? item.name);
142
+ if (missingFiat.length > 0) {
143
+ throw new Error(`Required visible assets lack formatted fiat values: ${missingFiat.join(', ')}.`);
144
+ }
145
+ }
146
+ const priceChangeCount = state.assets.filter((item) => item.priceChange !== null).length;
147
+ if (priceChangeCount < options.minimumPriceChangeCount) {
148
+ throw new Error(
149
+ `Expected price changes for at least ${options.minimumPriceChangeCount} visible assets, but observed ${priceChangeCount}.`,
150
+ );
151
+ }
152
+ const withoutPriceChangeCount = state.assets.length - priceChangeCount;
153
+ if (withoutPriceChangeCount < options.minimumWithoutPriceChangeCount) {
154
+ throw new Error(
155
+ `Expected at least ${options.minimumWithoutPriceChangeCount} clean assets without price-change data, but observed ${withoutPriceChangeCount}.`,
156
+ );
157
+ }
158
+ if (state.malformedValueCount > 0) {
159
+ throw new Error(`Visible asset values contain ${state.malformedValueCount} malformed entries.`);
160
+ }
161
+ if (options.requirePriceChangeStyling) {
162
+ const invalidStyles = state.assets
163
+ .filter((item) => item.priceChange !== null)
164
+ .filter((item) => {
165
+ const numeric = Number(item.priceChange.replace('%', ''));
166
+ if (!Number.isFinite(numeric)) return true;
167
+ if (numeric < 0) return item.priceChangeTone !== 'negative';
168
+ if (numeric > 0) return item.priceChangeTone !== 'positive';
169
+ return item.priceChangeTone !== 'neutral';
170
+ })
171
+ .map((item) => item.symbol ?? item.name);
172
+ if (invalidStyles.length > 0) {
173
+ throw new Error(`Visible price-change styling does not match its sign for: ${invalidStyles.join(', ')}.`);
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ normalizeVisibleAssetsState,
3
+ resolveVisibleAssetsOptions,
4
+ } from '../assets/visible-state.mjs';
5
+
6
+ function boundedInteger(value, fallback, minimum, maximum, name) {
7
+ const resolved = value == null ? fallback : Number(value);
8
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
9
+ throw new Error(
10
+ `metamask.networks.read_visible_state ${name} must be an integer from ${minimum} to ${maximum}.`,
11
+ );
12
+ }
13
+ return resolved;
14
+ }
15
+
16
+ function normalizedText(value) {
17
+ return typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '';
18
+ }
19
+
20
+ export function resolveVisibleNetworkOptions(node = {}) {
21
+ const network = normalizedText(node.network);
22
+ if (!network) throw new Error('metamask.networks.read_visible_state requires network.');
23
+ return {
24
+ network,
25
+ chainId: node.chain_id == null ? undefined : boundedInteger(node.chain_id, 0, 1, 2 ** 31 - 1, 'chain_id'),
26
+ settleMs: boundedInteger(node.settle_ms, 0, 0, 60_000, 'settle_ms'),
27
+ assets: resolveVisibleAssetsOptions(node),
28
+ };
29
+ }
30
+
31
+ export function normalizeVisibleNetworkState(beforeRaw, afterRaw, options, platform) {
32
+ const selectedNetwork = normalizedText(afterRaw.network ?? beforeRaw.network);
33
+ if (!selectedNetwork.toLowerCase().includes(options.network.toLowerCase())) {
34
+ throw new Error(
35
+ `Expected selected network ${options.network}, but observed ${selectedNetwork || 'none'}.`,
36
+ );
37
+ }
38
+ const forbiddenError = 'none of the cryptocurrencies are supported by price api';
39
+ const errors = [beforeRaw.errorText, afterRaw.errorText]
40
+ .map(normalizedText)
41
+ .filter((value) => value.toLowerCase().includes(forbiddenError));
42
+ if (errors.length > 0) {
43
+ throw new Error(`Selected network ${options.network} displayed a price API error.`);
44
+ }
45
+ const before = normalizeVisibleAssetsState(beforeRaw, options.assets, platform);
46
+ const after = normalizeVisibleAssetsState(afterRaw, options.assets, platform);
47
+ const afterBySymbol = new Map(after.assets.map((asset) => [asset.symbol, asset]));
48
+ const unstable = before.assets
49
+ .filter((asset) => options.assets.requireSymbols.length === 0 || options.assets.requireSymbols.includes(asset.symbol))
50
+ .filter((asset) => {
51
+ const current = afterBySymbol.get(asset.symbol);
52
+ return !current || current.amount !== asset.amount;
53
+ })
54
+ .map((asset) => asset.symbol ?? asset.name);
55
+ if (unstable.length > 0) {
56
+ throw new Error(`Network asset rows did not persist across the settle window: ${unstable.join(', ')}.`);
57
+ }
58
+ return {
59
+ source: 'visible-ui',
60
+ platform,
61
+ network: options.network,
62
+ ...(options.chainId ? { chainId: options.chainId } : {}),
63
+ selectedNetwork,
64
+ settleMs: options.settleMs,
65
+ stable: true,
66
+ before: before.assets,
67
+ after: after.assets,
68
+ priceApiError: false,
69
+ };
70
+ }
@@ -0,0 +1,354 @@
1
+ const SURFACES = new Set([
2
+ 'auto',
3
+ 'home',
4
+ 'market-list',
5
+ 'market-details',
6
+ 'order-entry',
7
+ 'activity',
8
+ 'funds',
9
+ ]);
10
+ const FEATURES = new Set([
11
+ 'activity',
12
+ 'balance',
13
+ 'chart',
14
+ 'long-short',
15
+ 'market-list',
16
+ 'order-entry',
17
+ 'price',
18
+ 'price-change',
19
+ 'stats',
20
+ ]);
21
+
22
+ function boundedInteger(value, fallback, minimum, maximum, name) {
23
+ const resolved = value == null ? fallback : Number(value);
24
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
25
+ throw new Error(
26
+ `metamask.perps.read_visible_state ${name} must be an integer from ${minimum} to ${maximum}.`,
27
+ );
28
+ }
29
+ return resolved;
30
+ }
31
+
32
+ function stringList(value, name, maximum = 100) {
33
+ if (value == null) return [];
34
+ if (!Array.isArray(value)) {
35
+ throw new Error(`metamask.perps.read_visible_state ${name} must be an array.`);
36
+ }
37
+ const values = Array.from(new Set(value.map((entry) => String(entry).trim()).filter(Boolean)));
38
+ if (values.length > maximum) {
39
+ throw new Error(`metamask.perps.read_visible_state ${name} accepts at most ${maximum} values.`);
40
+ }
41
+ return values;
42
+ }
43
+
44
+ export function resolveVisibleStateOptions(node = {}) {
45
+ const surface = String(node.surface ?? 'auto').toLowerCase();
46
+ if (!SURFACES.has(surface)) {
47
+ throw new Error(
48
+ `metamask.perps.read_visible_state received unsupported surface ${JSON.stringify(surface)}.`,
49
+ );
50
+ }
51
+ const market = node.market == null ? undefined : String(node.market).trim().toUpperCase();
52
+ if (node.market != null && !market) {
53
+ throw new Error('metamask.perps.read_visible_state market must not be empty.');
54
+ }
55
+ return {
56
+ surface,
57
+ market,
58
+ includeOffscreen: node.include_offscreen !== false,
59
+ maxItems: boundedInteger(node.max_items, 100, 1, 200, 'max_items'),
60
+ minimumMarketCount: boundedInteger(
61
+ node.minimum_market_count,
62
+ 0,
63
+ 0,
64
+ 200,
65
+ 'minimum_market_count',
66
+ ),
67
+ maximumMarketCount: boundedInteger(
68
+ node.maximum_market_count,
69
+ 200,
70
+ 0,
71
+ 200,
72
+ 'maximum_market_count',
73
+ ),
74
+ requireTestIds: stringList(node.require_test_ids, 'require_test_ids'),
75
+ forbidTestIds: stringList(node.forbid_test_ids, 'forbid_test_ids'),
76
+ direction: node.direction == null ? undefined : String(node.direction).trim().toLowerCase(),
77
+ orderType: node.order_type == null ? undefined : String(node.order_type).trim().toLowerCase(),
78
+ amount: node.amount == null ? undefined : String(node.amount),
79
+ percent: node.percent == null ? undefined : String(node.percent),
80
+ leverage: node.leverage == null ? undefined : String(node.leverage),
81
+ autoClose: node.auto_close == null ? undefined : Boolean(node.auto_close),
82
+ submitEnabled: node.submit_enabled == null ? undefined : Boolean(node.submit_enabled),
83
+ requireValidationText: stringList(node.require_validation_text, 'require_validation_text', 20),
84
+ minimumPositionCount: boundedInteger(node.minimum_position_count, 0, 0, 200, 'minimum_position_count'),
85
+ maximumPositionCount: boundedInteger(node.maximum_position_count, 200, 0, 200, 'maximum_position_count'),
86
+ minimumOrderCount: boundedInteger(node.minimum_order_count, 0, 0, 200, 'minimum_order_count'),
87
+ maximumOrderCount: boundedInteger(node.maximum_order_count, 200, 0, 200, 'maximum_order_count'),
88
+ minimumActivityCount: boundedInteger(node.minimum_activity_count, 0, 0, 200, 'minimum_activity_count'),
89
+ requireNoHorizontalOverflow: node.require_no_horizontal_overflow === true,
90
+ requireFeatures: stringList(node.require_features, 'require_features').map((feature) => {
91
+ if (!FEATURES.has(feature)) {
92
+ throw new Error(
93
+ `metamask.perps.read_visible_state received unsupported feature ${JSON.stringify(feature)}.`,
94
+ );
95
+ }
96
+ return feature;
97
+ }),
98
+ };
99
+ }
100
+
101
+ function text(value) {
102
+ if (typeof value !== 'string') return undefined;
103
+ const normalized = value.replace(/\s+/gu, ' ').trim();
104
+ return normalized ? normalized.slice(0, 500) : undefined;
105
+ }
106
+
107
+ function equalNumericText(actual, expected) {
108
+ const left = String(actual ?? '').trim();
109
+ const right = String(expected ?? '').trim();
110
+ if (!left || !right) return left === right;
111
+ const leftNumber = Number(left);
112
+ const rightNumber = Number(right);
113
+ return Number.isFinite(leftNumber) && Number.isFinite(rightNumber)
114
+ ? leftNumber === rightNumber
115
+ : left === right;
116
+ }
117
+
118
+ function normalizedItem(source) {
119
+ const testId = text(source?.testId ?? source?.test_id ?? source?.identifier);
120
+ if (!testId) return undefined;
121
+ return {
122
+ testId,
123
+ ...(text(source?.label ?? source?.value) ? { label: text(source?.label ?? source?.value) } : {}),
124
+ ...(text(source?.role ?? source?.type) ? { role: text(source?.role ?? source?.type) } : {}),
125
+ enabled: source?.enabled !== false,
126
+ visible: source?.visible !== false,
127
+ };
128
+ }
129
+
130
+ function marketFromTestId(testId) {
131
+ if (testId.startsWith('market-row-ticker-')) return undefined;
132
+ for (const prefix of ['perps-watchlist-', 'explore-markets-', 'perps-market-row-item-', 'market-row-']) {
133
+ if (!testId.startsWith(prefix)) continue;
134
+ const raw = testId.slice(prefix.length);
135
+ if ((prefix === 'explore-markets-' || prefix === 'market-row-') && raw.startsWith('xyz-')) {
136
+ return `XYZ:${raw.slice(4).toUpperCase()}`;
137
+ }
138
+ return raw.toUpperCase();
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function classifySurface(route, testIds) {
144
+ const joined = [...testIds].join('\n');
145
+ if (/\/perps\/trade\//u.test(route)) {
146
+ return 'order-entry';
147
+ }
148
+ if (/\/perps\/market\//u.test(route)) {
149
+ return 'market-details';
150
+ }
151
+ if (/perps-(?:order-entry|order-view|trade)/u.test(joined)) return 'order-entry';
152
+ if (/perps-(?:market-detail|market-details|pro-market)/u.test(joined)) return 'market-details';
153
+ if (/market-list-view|perps-market-list/u.test(joined)) return 'market-list';
154
+ if (/perps-(?:activity|transactions)/u.test(joined)) return 'activity';
155
+ if (/perps-(?:deposit|withdraw|add-funds)/u.test(joined)) return 'funds';
156
+ if (/perps-home/u.test(joined) || /perps-home|tab=perps/u.test(route)) return 'home';
157
+ return 'unknown';
158
+ }
159
+
160
+ function selected(items, predicate, maximum) {
161
+ return items.filter(predicate).slice(0, maximum);
162
+ }
163
+
164
+ function compactEntries(items, maximum) {
165
+ return selected(items, () => true, maximum).map((item) => ({
166
+ testId: item.testId,
167
+ ...(item.label ? { label: item.label } : {}),
168
+ enabled: item.enabled,
169
+ visible: item.visible,
170
+ }));
171
+ }
172
+
173
+ function visibleFeatures(items) {
174
+ const ids = new Set(items.map((item) => item.testId));
175
+ const has = (pattern) => [...ids].some((testId) => pattern.test(testId));
176
+ const long = has(/(?:long-cta-button|direction-long)$/u);
177
+ const short = has(/(?:short-cta-button|direction-short)$/u);
178
+ return {
179
+ activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
180
+ balance: has(/^perps-balance-dropdown/u),
181
+ chart: has(/perps-(?:candlestick-chart|market-detail-chart|pro-market-chart)/u),
182
+ 'long-short': long && short,
183
+ 'market-list': has(/^(?:market-list-view|perps-market-list|perps-watchlist-|explore-markets-|market-row-)/u),
184
+ 'order-entry': has(/perps-(?:order-entry|pro-order-form)/u),
185
+ price: has(/perps-(?:market-detail-price|pro-market-price)$/u),
186
+ 'price-change': has(/perps-(?:market-detail-change|pro-market-price-change)$/u),
187
+ stats: has(/perps-(?:stats-section|market-detail-oracle-price|pro-market-stats)/u),
188
+ };
189
+ }
190
+
191
+ export function normalizeVisiblePerpsState(raw, options, platform) {
192
+ const items = [...(raw.items ?? []), ...(options.includeOffscreen ? raw.offscreenItems ?? [] : [])]
193
+ .map(normalizedItem)
194
+ .filter(Boolean);
195
+ const unique = Array.from(new Map(items.map((item) => [item.testId, item])).values());
196
+ const testIds = new Set(unique.map((item) => item.testId));
197
+ const route = text(raw.route) ?? '';
198
+ const surface = classifySurface(route, testIds);
199
+ const marketItems = selected(
200
+ unique,
201
+ (item) => Boolean(marketFromTestId(item.testId)),
202
+ options.maxItems,
203
+ ).map((item) => ({
204
+ market: marketFromTestId(item.testId),
205
+ testId: item.testId,
206
+ ...(item.label ? { label: item.label } : {}),
207
+ visible: item.visible,
208
+ }));
209
+ const state = {
210
+ source: 'visible-ui',
211
+ platform,
212
+ surface,
213
+ ...(route ? { route } : {}),
214
+ ...(text(raw.title) ? { title: text(raw.title) } : {}),
215
+ balance: compactEntries(
216
+ selected(unique, (item) => item.testId.startsWith('perps-balance-dropdown'), 1),
217
+ 1,
218
+ )[0] ?? null,
219
+ markets: marketItems,
220
+ positions: compactEntries(
221
+ selected(unique, (item) => /^(?:position-card-|perps-position-row-|perps-position-size-value$|perps-pro-market-position-row-)/u.test(item.testId), options.maxItems),
222
+ options.maxItems,
223
+ ),
224
+ orders: compactEntries(
225
+ selected(unique, (item) => /^(?:order-card-|perps-order-row-)/u.test(item.testId), options.maxItems),
226
+ options.maxItems,
227
+ ),
228
+ activity: compactEntries(
229
+ selected(unique, (item) => /^(?:transaction-card-|perps-activity-row-)/u.test(item.testId), options.maxItems),
230
+ options.maxItems,
231
+ ),
232
+ orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
233
+ symbol: text(raw.orderEntry.symbol) ?? null,
234
+ direction: text(raw.orderEntry.direction) ?? null,
235
+ orderType: text(raw.orderEntry.orderType) ?? null,
236
+ amount: raw.orderEntry.amount == null ? null : String(raw.orderEntry.amount),
237
+ percent: raw.orderEntry.percent == null ? null : String(raw.orderEntry.percent),
238
+ leverage: raw.orderEntry.leverage == null ? null : String(raw.orderEntry.leverage),
239
+ autoClose: raw.orderEntry.autoClose === true,
240
+ takeProfitPrice: raw.orderEntry.takeProfitPrice == null ? null : String(raw.orderEntry.takeProfitPrice),
241
+ stopLossPrice: raw.orderEntry.stopLossPrice == null ? null : String(raw.orderEntry.stopLossPrice),
242
+ submit: raw.orderEntry.submit && typeof raw.orderEntry.submit === 'object' ? {
243
+ label: text(raw.orderEntry.submit.label) ?? null,
244
+ enabled: raw.orderEntry.submit.enabled !== false,
245
+ } : null,
246
+ limitPrice: raw.orderEntry.limitPrice == null ? null : String(raw.orderEntry.limitPrice),
247
+ validation: Array.isArray(raw.orderEntry.validation)
248
+ ? raw.orderEntry.validation.map(text).filter(Boolean).slice(0, 20)
249
+ : [],
250
+ } : null,
251
+ features: visibleFeatures(unique),
252
+ controls: compactEntries(
253
+ selected(
254
+ unique,
255
+ (item) => item.testId.startsWith('perps-') && !marketFromTestId(item.testId),
256
+ options.maxItems,
257
+ ),
258
+ options.maxItems,
259
+ ),
260
+ observedTestIds: Array.from(testIds).slice(0, options.maxItems),
261
+ truncated: raw.truncated === true || unique.length > options.maxItems,
262
+ layout: raw.layout && typeof raw.layout === 'object' ? {
263
+ viewportWidth: Number(raw.layout.viewportWidth ?? 0),
264
+ viewportHeight: Number(raw.layout.viewportHeight ?? 0),
265
+ contentWidth: Number(raw.layout.contentWidth ?? 0),
266
+ horizontalOverflow: raw.layout.horizontalOverflow === true,
267
+ clippedTestIds: Array.isArray(raw.layout.clippedTestIds)
268
+ ? raw.layout.clippedTestIds.map(text).filter(Boolean).slice(0, 100)
269
+ : [],
270
+ } : null,
271
+ };
272
+ assertVisibleState(state, options);
273
+ return state;
274
+ }
275
+
276
+ function assertVisibleState(state, options) {
277
+ if (options.requireNoHorizontalOverflow && (
278
+ !state.layout || state.layout.horizontalOverflow || state.layout.clippedTestIds.length > 0
279
+ )) {
280
+ throw new Error(`Visible Perps layout overflows horizontally: ${JSON.stringify(state.layout)}.`);
281
+ }
282
+ if (options.surface !== 'auto' && state.surface !== options.surface) {
283
+ throw new Error(
284
+ `Expected visible Perps surface ${options.surface}, but observed ${state.surface}.`,
285
+ );
286
+ }
287
+ if (state.markets.length < options.minimumMarketCount) {
288
+ throw new Error(
289
+ `Expected at least ${options.minimumMarketCount} visible Perps markets, but observed ${state.markets.length}.`,
290
+ );
291
+ }
292
+ if (state.markets.length > options.maximumMarketCount) {
293
+ throw new Error(
294
+ `Expected at most ${options.maximumMarketCount} visible Perps markets, but observed ${state.markets.length}.`,
295
+ );
296
+ }
297
+ if (state.positions.length < options.minimumPositionCount || state.positions.length > options.maximumPositionCount) {
298
+ throw new Error(`Expected ${options.minimumPositionCount}-${options.maximumPositionCount} visible Perps positions, but observed ${state.positions.length}.`);
299
+ }
300
+ if (state.orders.length < options.minimumOrderCount || state.orders.length > options.maximumOrderCount) {
301
+ throw new Error(`Expected ${options.minimumOrderCount}-${options.maximumOrderCount} visible Perps orders, but observed ${state.orders.length}.`);
302
+ }
303
+ if (state.activity.length < options.minimumActivityCount) {
304
+ throw new Error(`Expected at least ${options.minimumActivityCount} visible Perps activity entries, but observed ${state.activity.length}.`);
305
+ }
306
+ const observed = new Set(state.observedTestIds);
307
+ const missing = options.requireTestIds.filter((testId) => !observed.has(testId));
308
+ if (missing.length > 0) {
309
+ throw new Error(`Missing required visible Perps test IDs: ${missing.join(', ')}.`);
310
+ }
311
+ const forbidden = options.forbidTestIds.filter((testId) => observed.has(testId));
312
+ if (forbidden.length > 0) {
313
+ throw new Error(`Forbidden visible Perps test IDs were observed: ${forbidden.join(', ')}.`);
314
+ }
315
+ const missingFeatures = options.requireFeatures.filter((feature) => state.features[feature] !== true);
316
+ if (missingFeatures.length > 0) {
317
+ throw new Error(`Missing required visible Perps features: ${missingFeatures.join(', ')}.`);
318
+ }
319
+ if (options.market) {
320
+ const requested = options.market.replace(/^XYZ-/u, 'XYZ:');
321
+ const matched = state.markets.some((entry) => entry.market === requested) ||
322
+ [...state.controls, ...state.positions, ...state.orders, ...state.activity]
323
+ .some((entry) => String(entry.label ?? entry.testId).toUpperCase().includes(requested));
324
+ if (!matched) {
325
+ throw new Error(`Expected visible Perps market ${requested}, but it was not observed.`);
326
+ }
327
+ }
328
+ if (options.direction && state.orderEntry?.direction !== options.direction) {
329
+ throw new Error(`Expected Perps order direction ${options.direction}, but observed ${state.orderEntry?.direction ?? 'none'}.`);
330
+ }
331
+ if (options.orderType && state.orderEntry?.orderType !== options.orderType) {
332
+ throw new Error(`Expected Perps order type ${options.orderType}, but observed ${state.orderEntry?.orderType ?? 'none'}.`);
333
+ }
334
+ if (options.amount !== undefined && !equalNumericText(state.orderEntry?.amount, options.amount)) {
335
+ throw new Error(`Expected Perps order amount ${options.amount}, but observed ${state.orderEntry?.amount ?? 'none'}.`);
336
+ }
337
+ if (options.percent !== undefined && !equalNumericText(state.orderEntry?.percent, options.percent)) {
338
+ throw new Error(`Expected Perps order percent ${options.percent}, but observed ${state.orderEntry?.percent ?? 'none'}.`);
339
+ }
340
+ if (options.leverage !== undefined && !equalNumericText(state.orderEntry?.leverage, options.leverage)) {
341
+ throw new Error(`Expected Perps leverage ${options.leverage}, but observed ${state.orderEntry?.leverage ?? 'none'}.`);
342
+ }
343
+ if (options.autoClose !== undefined && state.orderEntry?.autoClose !== options.autoClose) {
344
+ throw new Error(`Expected Perps auto-close ${options.autoClose}, but observed ${state.orderEntry?.autoClose ?? 'none'}.`);
345
+ }
346
+ if (options.submitEnabled !== undefined && state.orderEntry?.submit?.enabled !== options.submitEnabled) {
347
+ throw new Error(`Expected Perps submit enabled ${options.submitEnabled}, but observed ${state.orderEntry?.submit?.enabled ?? 'none'}.`);
348
+ }
349
+ const validationText = state.orderEntry?.validation?.join(' ') ?? '';
350
+ const missingValidation = options.requireValidationText.filter((entry) => !validationText.includes(entry));
351
+ if (missingValidation.length > 0) {
352
+ throw new Error(`Missing required Perps validation text: ${missingValidation.join(', ')}.`);
353
+ }
354
+ }
@@ -0,0 +1,54 @@
1
+ function requiredText(value, name) {
2
+ const resolved = String(value ?? '').trim();
3
+ if (!resolved) throw new Error(`metamask.swap_bridge ${name} is required.`);
4
+ return resolved;
5
+ }
6
+
7
+ function timeout(value) {
8
+ const resolved = Number(value ?? 60_000);
9
+ if (!Number.isInteger(resolved) || resolved < 1_000 || resolved > 180_000) {
10
+ throw new Error('metamask.swap_bridge timeout_ms must be an integer from 1000 to 180000.');
11
+ }
12
+ return resolved;
13
+ }
14
+
15
+ function plainDecimal(value) {
16
+ const text = String(value).trim();
17
+ const match = /^([+-]?)(\d+)(?:\.(\d*))?[eE]([+-]?\d+)$/u.exec(text);
18
+ if (!match) return text;
19
+ const [, sign, integer, fraction = '', exponentText] = match;
20
+ const digits = `${integer}${fraction}`;
21
+ const decimalIndex = integer.length + Number(exponentText);
22
+ if (decimalIndex <= 0) return `${sign}0.${'0'.repeat(-decimalIndex)}${digits}`;
23
+ if (decimalIndex >= digits.length) return `${sign}${digits}${'0'.repeat(decimalIndex - digits.length)}`;
24
+ return `${sign}${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`;
25
+ }
26
+
27
+ export function resolveAssetSelectionOptions(node = {}) {
28
+ const sourceSymbol = requiredText(node.source_symbol, 'source_symbol').toUpperCase();
29
+ const destinationSymbol = requiredText(node.destination_symbol, 'destination_symbol').toUpperCase();
30
+ return {
31
+ sourceSymbol,
32
+ destinationSymbol,
33
+ sourceChain: requiredText(node.source_chain, 'source_chain'),
34
+ destinationChain: requiredText(node.destination_chain, 'destination_chain'),
35
+ sourceQuery: String(node.source_query ?? sourceSymbol).trim(),
36
+ destinationQuery: String(node.destination_query ?? destinationSymbol).trim(),
37
+ sourceAssetId: String(node.source_asset_id ?? '').trim() || undefined,
38
+ destinationAssetId: String(node.destination_asset_id ?? '').trim() || undefined,
39
+ timeoutMs: timeout(node.timeout_ms),
40
+ };
41
+ }
42
+
43
+ export function resolveAmountOptions(node = {}) {
44
+ const amount = plainDecimal(requiredText(node.amount, 'amount'));
45
+ const numeric = Number(amount);
46
+ if (!Number.isFinite(numeric) || numeric <= 0) {
47
+ throw new Error('metamask.swap_bridge amount must be a positive number.');
48
+ }
49
+ return {
50
+ amount,
51
+ waitForQuote: node.wait_for_quote !== false,
52
+ timeoutMs: timeout(node.timeout_ms),
53
+ };
54
+ }