@deeeed/metamask-harness 0.19.1 → 0.20.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 (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/adapters/extension/ensure-browser.sh +6 -4
  3. package/adapters/extension/launch-browser.cjs +6 -1
  4. package/adapters/extension/lib/chrome-args.cjs +11 -1
  5. package/adapters/extension/start-watch.sh +1 -1
  6. package/adapters/extension/verify.sh +17 -0
  7. package/adapters/mobile/open-device.sh +12 -1
  8. package/adapters/mobile/wait-for-bridge.sh +1 -1
  9. package/dist/adapters/extension/ensure-ready.js +7 -2
  10. package/dist/adapters/extension/runtime-decision.js +4 -6
  11. package/dist/adapters/extension/runtime.js +114 -17
  12. package/dist/adapters/mobile/prepare.js +40 -6
  13. package/dist/command-contract.js +58 -5
  14. package/dist/commands/call.js +6 -3
  15. package/dist/commands/check.js +9 -2
  16. package/dist/commands/checklist.js +117 -14
  17. package/dist/commands/launch/extension.js +8 -3
  18. package/dist/commands/launch/index.js +38 -25
  19. package/dist/commands/launch/mobile.js +2 -2
  20. package/dist/commands/manifest.js +72 -5
  21. package/dist/commands/run-engine.js +7 -2
  22. package/dist/heal-bounds.js +1 -1
  23. package/dist/metamask-action-validation.js +45 -0
  24. package/dist/mm-harness-cli.js +7 -4
  25. package/docs/CONTRIBUTING.md +8 -0
  26. package/library/actions/extension/platform/cdp.mjs +8 -3
  27. package/library/actions/extension/ui/navigate.mjs +239 -16
  28. package/library/actions/mobile/ui/navigate.mjs +1 -1
  29. package/library/manifests/core.action-manifest.json +60 -7
  30. package/library/manifests/extension.action-manifest.json +55 -13
  31. package/library/manifests/mobile.action-manifest.json +52 -12
  32. package/library/recipes/perps/lifecycle.recipe.json +3 -9
  33. package/library/recipes/runner/action-validation.extension.recipe.json +5 -4
  34. package/package.json +5 -4
  35. package/scripts/completions.sh +2 -2
@@ -575,10 +575,15 @@ export class ExtensionPage extends CdpWebPage {
575
575
  this.port = port;
576
576
  }
577
577
 
578
- async navigateHash(hash) {
578
+ async navigateHash(hash, timeoutMs = 10000) {
579
579
  const normalizedHash = String(hash || '').startsWith('#') ? hash : `#${hash || '/'}`;
580
- const href = `${this.origin}/home.html${normalizedHash}`;
581
- return this.navigate(href);
580
+ const navigation = await this.evaluate(`(() => {
581
+ const before = location.href;
582
+ location.hash = ${JSON.stringify(normalizedHash)};
583
+ return { before, href: location.href };
584
+ })()`);
585
+ await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
586
+ return { ...navigation, sameDocument: true };
582
587
  }
583
588
 
584
589
  async readPositions() {
@@ -1,31 +1,249 @@
1
- import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
1
+ import { pathToFileURL } from 'node:url';
2
2
 
3
- const PAGE_HASHES = {
4
- home: '#/',
5
- perps: '#/perps-home',
6
- };
3
+ import {
4
+ dataTestId,
5
+ normalizeMarketSymbol,
6
+ runAdapter,
7
+ withExtensionPage,
8
+ } from '../platform/cdp.mjs';
9
+
10
+ const BACK_CONTROLS = [
11
+ 'perps-order-entry-back-button',
12
+ 'perps-market-detail-back-button',
13
+ 'perps-activity-back-button',
14
+ 'perps-withdraw-back-button',
15
+ 'back-button',
16
+ ].map(dataTestId);
7
17
 
8
18
  function text(value) {
9
19
  return typeof value === 'string' && value.length > 0 ? value : undefined;
10
20
  }
11
21
 
12
- function pageHash(node) {
22
+ function pageIntent(node) {
13
23
  const page = text(node?.page);
14
24
  if (!page) return undefined;
15
- if (PAGE_HASHES[page]) return { page, hash: PAGE_HASHES[page] };
25
+ if (page === 'home' || page === 'perps') return { page };
16
26
  if (page === 'perps-market') {
17
27
  const market = text(node.market) ?? text(node.symbol);
18
28
  if (!market) throw new Error('extension ui.navigate page=perps-market requires market or symbol.');
19
- return { page, hash: `#/perps/market/${encodeURIComponent(market)}` };
29
+ return { page, market };
20
30
  }
21
- throw new Error('extension ui.navigate supported page aliases: home, perps, perps-market.');
31
+ throw new Error('extension ui.navigate supported page intents: home, perps, perps-market.');
32
+ }
33
+
34
+ async function hasVisibleSelector(page, selector) {
35
+ return page.evaluate(`(() => {
36
+ const element = document.querySelector(${JSON.stringify(selector)});
37
+ if (!element) return false;
38
+ const style = getComputedStyle(element);
39
+ const rect = element.getBoundingClientRect();
40
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
41
+ })()`);
42
+ }
43
+
44
+ async function pauseForUi(page) {
45
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
22
46
  }
23
47
 
24
- runAdapter((input) => withExtensionPage(input, async (page) => {
25
- const alias = pageHash(input.node);
26
- if (alias) {
27
- const navigation = await page.navigateHash(alias.hash);
28
- return { action: input.action, ...alias, navigation, proofPath: 'ui-navigation' };
48
+ async function waitForFirstVisible(page, selectors, deadline) {
49
+ while (Date.now() < deadline) {
50
+ for (const selector of selectors) {
51
+ if (await hasVisibleSelector(page, selector)) return selector;
52
+ }
53
+ await pauseForUi(page);
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ async function currentHref(page) {
59
+ return page.evaluate('location.href');
60
+ }
61
+
62
+ async function activateSemanticControl(page, selector) {
63
+ return {
64
+ activation: 'trusted-pointer',
65
+ interaction: await page.click(selector),
66
+ };
67
+ }
68
+
69
+ export async function openHome(page, timeoutMs) {
70
+ const home = dataTestId('bottom-nav-home');
71
+ const selectedHome = `${home}[aria-current="page"]`;
72
+ const deadline = Date.now() + timeoutMs;
73
+ const steps = [];
74
+ let pendingActivation;
75
+
76
+ while (Date.now() < deadline) {
77
+ if (await hasVisibleSelector(page, selectedHome)) {
78
+ return { method: 'visible-ui', href: await currentHref(page), steps };
79
+ }
80
+ const href = await currentHref(page);
81
+ if (pendingActivation && pendingActivation.href !== href) {
82
+ pendingActivation = undefined;
83
+ await pauseForUi(page);
84
+ continue;
85
+ }
86
+ if (await hasVisibleSelector(page, home)) {
87
+ if (pendingActivation?.selector !== home) pendingActivation = undefined;
88
+ if (!pendingActivation) {
89
+ await activateSemanticControl(page, home);
90
+ steps.push(home);
91
+ pendingActivation = { selector: home, href };
92
+ }
93
+ await pauseForUi(page);
94
+ continue;
95
+ }
96
+ let backControl;
97
+ for (const selector of BACK_CONTROLS) {
98
+ if (await hasVisibleSelector(page, selector)) {
99
+ backControl = selector;
100
+ break;
101
+ }
102
+ }
103
+ if (backControl) {
104
+ if (pendingActivation?.selector !== backControl) pendingActivation = undefined;
105
+ if (!pendingActivation || pendingActivation.selector !== backControl) {
106
+ await activateSemanticControl(page, backControl);
107
+ steps.push(backControl);
108
+ pendingActivation = { selector: backControl, href };
109
+ }
110
+ }
111
+ await pauseForUi(page);
112
+ }
113
+
114
+ throw new Error(
115
+ `extension ui.navigate page=home could not resolve Home through visible controls; observed href ${JSON.stringify(await currentHref(page))}.`,
116
+ );
117
+ }
118
+
119
+ export async function openPerpsHome(page, timeoutMs) {
120
+ const bottomNav = dataTestId('bottom-nav-perps');
121
+ const legacyTab = dataTestId('account-overview__perps-tab');
122
+ const perpsHome = dataTestId('perps-home-page');
123
+ const steps = [];
124
+ const deadline = Date.now() + timeoutMs;
125
+ let pendingActivation;
126
+ let perpsActivation;
127
+
128
+ while (Date.now() < deadline) {
129
+ if (await hasVisibleSelector(page, perpsHome)) {
130
+ return {
131
+ ...(perpsActivation ? { navigation: perpsActivation, selector: bottomNav } : {}),
132
+ method: 'visible-ui',
133
+ href: await currentHref(page),
134
+ steps,
135
+ };
136
+ }
137
+ if (await hasVisibleSelector(page, bottomNav)) {
138
+ if (!perpsActivation) {
139
+ perpsActivation = await activateSemanticControl(page, bottomNav);
140
+ steps.push(bottomNav);
141
+ }
142
+ await pauseForUi(page);
143
+ continue;
144
+ }
145
+ if (await hasVisibleSelector(page, legacyTab)) {
146
+ const navigation = await activateSemanticControl(page, legacyTab);
147
+ await page.waitForSelector(dataTestId('perps-view'), {
148
+ timeoutMs: Math.max(1, deadline - Date.now()),
149
+ });
150
+ return {
151
+ navigation,
152
+ method: 'visible-ui',
153
+ href: await currentHref(page),
154
+ selector: legacyTab,
155
+ steps,
156
+ };
157
+ }
158
+
159
+ let backControl;
160
+ for (const selector of BACK_CONTROLS) {
161
+ if (await hasVisibleSelector(page, selector)) {
162
+ backControl = selector;
163
+ break;
164
+ }
165
+ }
166
+ if (backControl) {
167
+ const href = await currentHref(page);
168
+ if (pendingActivation && pendingActivation.href !== href) {
169
+ pendingActivation = undefined;
170
+ }
171
+ if (!pendingActivation || pendingActivation.selector !== backControl) {
172
+ await activateSemanticControl(page, backControl);
173
+ steps.push(backControl);
174
+ pendingActivation = { selector: backControl, href };
175
+ }
176
+ }
177
+ await pauseForUi(page);
178
+ }
179
+
180
+ const state = await page.evaluate(`({
181
+ href: location.href,
182
+ title: document.title,
183
+ visibleTestIds: [...document.querySelectorAll('[data-testid]')]
184
+ .filter((element) => {
185
+ const style = getComputedStyle(element);
186
+ const rect = element.getBoundingClientRect();
187
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
188
+ })
189
+ .map((element) => element.getAttribute('data-testid'))
190
+ .filter(Boolean)
191
+ .slice(0, 30),
192
+ })`);
193
+ throw new Error(
194
+ `extension ui.navigate page=perps could not resolve the Perps home through visible UI; observed ${JSON.stringify(state)}. Capture the current UI with mm-harness call ui.screenshot, then use a verified visible control or raw hash/path.`,
195
+ );
196
+ }
197
+
198
+ export async function openPerpsMarket(page, market, timeoutMs) {
199
+ const deadline = Date.now() + timeoutMs;
200
+ const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
201
+ const normalizedMarket = normalizeMarketSymbol(market.trim());
202
+ const selectors = [
203
+ dataTestId(`perps-watchlist-${normalizedMarket}`),
204
+ dataTestId(`explore-markets-${normalizedMarket.replaceAll(':', '-')}`),
205
+ ];
206
+ const marketControl = await waitForFirstVisible(page, selectors, deadline);
207
+ if (!marketControl) {
208
+ const visibleMarketControls = await page.evaluate(`[
209
+ ...document.querySelectorAll('[data-testid^="perps-watchlist-"], [data-testid^="explore-markets-"]')
210
+ ].filter((element) => {
211
+ const style = getComputedStyle(element);
212
+ const rect = element.getBoundingClientRect();
213
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
214
+ }).map((element) => element.getAttribute('data-testid')).filter(Boolean)`);
215
+ throw new Error(
216
+ `extension ui.navigate page=perps-market could not find ${JSON.stringify(normalizedMarket)} in the current visible market controls ${JSON.stringify(visibleMarketControls)}.`,
217
+ );
218
+ }
219
+ const navigation = await activateSemanticControl(page, marketControl);
220
+ await page.waitForSelector(dataTestId('perps-market-detail-page'), {
221
+ timeoutMs: Math.max(1, deadline - Date.now()),
222
+ });
223
+ return {
224
+ navigation,
225
+ method: 'visible-ui',
226
+ href: await currentHref(page),
227
+ selector: marketControl,
228
+ steps: [...home.steps, marketControl],
229
+ };
230
+ }
231
+
232
+ export async function navigateExtensionUi(input) {
233
+ return withExtensionPage(input, async (page) => {
234
+ const intent = pageIntent(input.node);
235
+ if (intent) {
236
+ const timeoutMs = Number(input.node?.timeout_ms ?? 20000);
237
+ if (intent.page === 'home') {
238
+ const result = await openHome(page, timeoutMs);
239
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
240
+ }
241
+ if (intent.page === 'perps') {
242
+ const result = await openPerpsHome(page, timeoutMs);
243
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
244
+ }
245
+ const result = await openPerpsMarket(page, intent.market, timeoutMs);
246
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
29
247
  }
30
248
 
31
249
  const url = input.node?.url;
@@ -40,5 +258,10 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
40
258
  return { action: input.action, hash, navigation, proofPath: 'ui-navigation' };
41
259
  }
42
260
 
43
- throw new Error('extension ui.navigate requires page, raw extension url, hash, or path.');
44
- }));
261
+ throw new Error('extension ui.navigate requires page, raw extension url, hash, or path.');
262
+ });
263
+ }
264
+
265
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
266
+ runAdapter(navigateExtensionUi);
267
+ }
@@ -18,7 +18,7 @@ function pageRoute(node) {
18
18
  if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
19
19
  return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market } } };
20
20
  }
21
- throw new Error('mobile ui.navigate supported page aliases: home, perps, perps-market.');
21
+ throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market.');
22
22
  }
23
23
 
24
24
  runAdapter(async (input) => {
@@ -838,7 +838,7 @@
838
838
  "long",
839
839
  "short"
840
840
  ],
841
- "description": "Order direction (default: long)."
841
+ "description": "Required order direction."
842
842
  },
843
843
  "order_type": {
844
844
  "type": "string",
@@ -857,11 +857,17 @@
857
857
  "description": "Resting limit price as a percent offset from live mid for limit orders (e.g. -30 = 30%% below mid for a non-filling BUY; default -30 buy / +30 sell). Alias: offsetPct."
858
858
  },
859
859
  "amount": {
860
- "type": "string",
860
+ "type": [
861
+ "string",
862
+ "number"
863
+ ],
861
864
  "description": "USD notional alias."
862
865
  },
863
866
  "notional": {
864
- "type": "string",
867
+ "type": [
868
+ "string",
869
+ "number"
870
+ ],
865
871
  "description": "USD notional alias."
866
872
  },
867
873
  "leverage": {
@@ -1233,7 +1239,7 @@
1233
1239
  "long",
1234
1240
  "short"
1235
1241
  ],
1236
- "description": "Order/position direction (default: long when placing)."
1242
+ "description": "Required when state=open/present; otherwise filters selected positions."
1237
1243
  },
1238
1244
  "state": {
1239
1245
  "type": "string",
@@ -1247,11 +1253,17 @@
1247
1253
  "description": "Desired selected position state to converge to."
1248
1254
  },
1249
1255
  "amount": {
1250
- "type": "string",
1256
+ "type": [
1257
+ "string",
1258
+ "number"
1259
+ ],
1251
1260
  "description": "USD notional alias (used when placing to reach state open)."
1252
1261
  },
1253
1262
  "notional": {
1254
- "type": "string",
1263
+ "type": [
1264
+ "string",
1265
+ "number"
1266
+ ],
1255
1267
  "description": "USD notional alias."
1256
1268
  },
1257
1269
  "leverage": {
@@ -1279,6 +1291,17 @@
1279
1291
  "market": "BTC",
1280
1292
  "intent": "Converge Perps positions to the requested state via the headless controller"
1281
1293
  }
1294
+ },
1295
+ {
1296
+ "description": "Ensure one ETH long position exists",
1297
+ "node": {
1298
+ "action": "metamask.perps.ensure_positions",
1299
+ "market": "ETH",
1300
+ "side": "long",
1301
+ "state": "open",
1302
+ "notional": "10",
1303
+ "intent": "Converge ETH positions to one explicit testnet precondition"
1304
+ }
1282
1305
  }
1283
1306
  ],
1284
1307
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or controller output.",
@@ -1610,11 +1633,29 @@
1610
1633
  "long",
1611
1634
  "short"
1612
1635
  ],
1613
- "description": "Optional position/order side filter."
1636
+ "description": "Required when state=open/present; otherwise filters selected orders."
1614
1637
  },
1615
1638
  "timeout_ms": {
1616
1639
  "type": "number"
1617
1640
  },
1641
+ "amount": {
1642
+ "type": [
1643
+ "string",
1644
+ "number"
1645
+ ],
1646
+ "description": "Explicit USD notional used only when creating a missing open order. Alias: notional."
1647
+ },
1648
+ "notional": {
1649
+ "type": [
1650
+ "string",
1651
+ "number"
1652
+ ],
1653
+ "description": "Alias for amount."
1654
+ },
1655
+ "leverage": {
1656
+ "type": "number",
1657
+ "description": "Leverage used only when creating a missing open order."
1658
+ },
1618
1659
  "state": {
1619
1660
  "type": "string",
1620
1661
  "enum": [
@@ -1641,6 +1682,18 @@
1641
1682
  "mode": "all",
1642
1683
  "intent": "Converge Perps orders to the requested state"
1643
1684
  }
1685
+ },
1686
+ {
1687
+ "description": "Ensure one resting ETH short order exists",
1688
+ "node": {
1689
+ "action": "metamask.perps.ensure_orders",
1690
+ "market": "ETH",
1691
+ "side": "short",
1692
+ "state": "open",
1693
+ "notional": "10",
1694
+ "leverage": 2,
1695
+ "intent": "Converge ETH open orders to one explicit testnet precondition"
1696
+ }
1644
1697
  }
1645
1698
  ],
1646
1699
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or target runtime output.",
@@ -220,7 +220,7 @@
220
220
  ]
221
221
  },
222
222
  "ui.navigate": {
223
- "description": "Navigate with either raw extension url/hash/path or a small stable page alias. Discover supported aliases here before hardcoding routes.",
223
+ "description": "Navigate by semantic page intent when available; the adapter uses the current visible UI or a verified route. Raw url/hash/path values must be verified against the current app first.",
224
224
  "schema": {
225
225
  "type": "object",
226
226
  "properties": {
@@ -257,14 +257,16 @@
257
257
  "type": "object"
258
258
  },
259
259
  "timeout_ms": {
260
- "type": "number"
260
+ "type": "number",
261
+ "default": 20000,
262
+ "description": "Maximum time to discover and verify the requested page intent."
261
263
  }
262
264
  },
263
265
  "additionalProperties": false
264
266
  },
265
267
  "examples": [
266
268
  {
267
- "description": "Open Perps through the stable alias",
269
+ "description": "Ask the adapter to discover and open the current Perps entry point",
268
270
  "node": {
269
271
  "action": "ui.navigate",
270
272
  "page": "perps",
@@ -272,7 +274,7 @@
272
274
  }
273
275
  },
274
276
  {
275
- "description": "Open a Perps market through the stable alias",
277
+ "description": "Open a Perps market through the current verified adapter path",
276
278
  "node": {
277
279
  "action": "ui.navigate",
278
280
  "page": "perps-market",
@@ -281,7 +283,7 @@
281
283
  }
282
284
  },
283
285
  {
284
- "description": "Fallback to a raw extension hash when no alias exists",
286
+ "description": "Use a raw extension hash only after verifying it in the current app",
285
287
  "node": {
286
288
  "action": "ui.navigate",
287
289
  "hash": "#/perps-home",
@@ -1606,17 +1608,23 @@
1606
1608
  "long",
1607
1609
  "short"
1608
1610
  ],
1609
- "description": "Optional position/order side filter."
1611
+ "description": "Required order direction."
1610
1612
  },
1611
1613
  "timeout_ms": {
1612
1614
  "type": "number"
1613
1615
  },
1614
1616
  "amount": {
1615
- "type": "string",
1617
+ "type": [
1618
+ "string",
1619
+ "number"
1620
+ ],
1616
1621
  "description": "USD amount/notional alias."
1617
1622
  },
1618
1623
  "notional": {
1619
- "type": "string",
1624
+ "type": [
1625
+ "string",
1626
+ "number"
1627
+ ],
1620
1628
  "description": "USD notional alias."
1621
1629
  },
1622
1630
  "size": {
@@ -1967,11 +1975,25 @@
1967
1975
  "long",
1968
1976
  "short"
1969
1977
  ],
1970
- "description": "Optional position/order side filter."
1978
+ "description": "Required when state=open/present; otherwise filters selected positions."
1971
1979
  },
1972
1980
  "timeout_ms": {
1973
1981
  "type": "number"
1974
1982
  },
1983
+ "amount": {
1984
+ "type": [
1985
+ "string",
1986
+ "number"
1987
+ ],
1988
+ "description": "Explicit USD notional used only when creating a missing open position. Alias: notional."
1989
+ },
1990
+ "notional": {
1991
+ "type": [
1992
+ "string",
1993
+ "number"
1994
+ ],
1995
+ "description": "Alias for amount."
1996
+ },
1975
1997
  "state": {
1976
1998
  "type": "string",
1977
1999
  "enum": [
@@ -2007,6 +2029,17 @@
2007
2029
  "mode": "all",
2008
2030
  "intent": "Converge Perps positions to the requested state"
2009
2031
  }
2032
+ },
2033
+ {
2034
+ "description": "Ensure one ETH long position exists",
2035
+ "node": {
2036
+ "action": "metamask.perps.ensure_positions",
2037
+ "market": "ETH",
2038
+ "side": "long",
2039
+ "state": "open",
2040
+ "notional": "10",
2041
+ "intent": "Converge ETH positions to one explicit testnet precondition"
2042
+ }
2010
2043
  }
2011
2044
  ],
2012
2045
  "proof_effect": "E2E validation must record this action in trace.json; live-proof actions include liveAdapter/proofPath or target runtime output.",
@@ -2097,15 +2130,24 @@
2097
2130
  "long",
2098
2131
  "short"
2099
2132
  ],
2100
- "description": "Optional position/order side filter."
2133
+ "description": "Required when state=open/present; otherwise filters selected orders."
2101
2134
  },
2102
2135
  "timeout_ms": {
2103
2136
  "type": "number"
2104
2137
  },
2138
+ "amount": {
2139
+ "type": [
2140
+ "string",
2141
+ "number"
2142
+ ],
2143
+ "description": "Explicit USD notional used only when creating a missing open order. Alias: notional."
2144
+ },
2105
2145
  "notional": {
2106
- "type": "number",
2107
- "default": 10,
2108
- "description": "Small testnet USD notional used only when creating a missing open order."
2146
+ "type": [
2147
+ "string",
2148
+ "number"
2149
+ ],
2150
+ "description": "Alias for amount."
2109
2151
  },
2110
2152
  "leverage": {
2111
2153
  "type": "number",