@deeeed/metamask-harness 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +6 -1
- package/adapters/mobile/stop-metro.sh +6 -6
- package/library/actions/mobile/perps/assert_orders.mjs +4 -1
- package/library/actions/mobile/perps/assert_positions.mjs +4 -1
- package/library/actions/mobile/perps/close_orders.mjs +2 -0
- package/library/actions/mobile/perps/close_positions.mjs +2 -0
- package/library/actions/mobile/perps/configure_order.mjs +90 -0
- package/library/actions/mobile/perps/order-input-state.mjs +48 -0
- package/library/actions/mobile/perps/order-receipt.mjs +241 -0
- package/library/actions/mobile/perps/read_order_input.mjs +33 -0
- package/library/actions/mobile/perps/read_order_receipt.mjs +5 -0
- package/library/manifests/mobile.action-manifest.json +51 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.50.0 - 2026-09-08
|
|
6
|
+
|
|
7
|
+
- Bind Mobile order assertions and cleanup to accepted receipts, exact TP/SL values and complete snapshots. iOS passed one limit cancellation and one protected-market close; protected limits, full recipes, CUF and Android remain unvalidated.
|
|
8
|
+
- Fix Mobile stop helper lookup and detached-target discovery for an explicitly selected device.
|
|
9
|
+
- Add Mobile amount/leverage input with exact mounted-form and native readback against the unchanged unsigned confirmation. Changed and already-satisfied $10.25/1x input passed on iOS.
|
|
10
|
+
|
|
11
|
+
## 0.49.1 - 2026-09-08
|
|
12
|
+
|
|
13
|
+
- fix(funding): bind balance evidence to the wallet runtime (#234).
|
|
14
|
+
|
|
5
15
|
## 0.49.0 - 2026-09-07
|
|
6
16
|
|
|
7
17
|
- Simplify Mobile setup and sizing; correct nested-route verification, lifecycle portability, wallet selection, input clearing and token-sort restoration.
|
|
@@ -431,7 +431,12 @@ function createCdpBroker({
|
|
|
431
431
|
readyOnly: true,
|
|
432
432
|
});
|
|
433
433
|
if (existing.length > 0) return Promise.resolve(existing);
|
|
434
|
-
|
|
434
|
+
const retained = targetList({ nameIncludes: params.nameIncludes });
|
|
435
|
+
requestDiscovery?.(
|
|
436
|
+
String(params.nameIncludes || '').trim() && retained.length === 1
|
|
437
|
+
? retained[0].deviceId
|
|
438
|
+
: '',
|
|
439
|
+
);
|
|
435
440
|
return new Promise((resolve, reject) => {
|
|
436
441
|
const waiter = {
|
|
437
442
|
nameIncludes: String(params.nameIncludes || ''),
|
|
@@ -23,9 +23,9 @@ done
|
|
|
23
23
|
|
|
24
24
|
TARGET="$(cd "$TARGET" && pwd -P)"
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
MOBILE_STOP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
27
|
# shellcheck disable=SC1091
|
|
28
|
-
. "$
|
|
28
|
+
. "$MOBILE_STOP_DIR/../shared/harness-path.sh"
|
|
29
29
|
if ! command -v recipe_runtime_dir >/dev/null 2>&1; then
|
|
30
30
|
echo "stop-metro: shared lib adapters/shared/harness-path.sh not found; reinstall the runner." >&2
|
|
31
31
|
exit 1
|
|
@@ -35,7 +35,7 @@ fi
|
|
|
35
35
|
# pool/formula fallbacks) so we stop THIS slot's Metro, not the 8081 default.
|
|
36
36
|
if [ -z "$PORT" ]; then
|
|
37
37
|
# shellcheck disable=SC1091
|
|
38
|
-
. "$
|
|
38
|
+
. "$MOBILE_STOP_DIR/../shared/resolve-slot-ports.sh"
|
|
39
39
|
resolved_port="$(resolve_mobile_runtime_ports "$TARGET" 2>/dev/null | sed -n 's/^WATCHER_PORT=//p' | head -1 || true)"
|
|
40
40
|
PORT="${resolved_port:-8081}"
|
|
41
41
|
fi
|
|
@@ -45,7 +45,7 @@ PID_FILE="$LOG_DIR/metro.pid"
|
|
|
45
45
|
TMUX_FILE="$LOG_DIR/metro.tmux"
|
|
46
46
|
|
|
47
47
|
# shellcheck disable=SC1091
|
|
48
|
-
. "$
|
|
48
|
+
. "$MOBILE_STOP_DIR/lib/metro-listener.sh"
|
|
49
49
|
|
|
50
50
|
pids="$(metro_listener_pids 2>/dev/null || true)"
|
|
51
51
|
if [ -n "$pids" ]; then
|
|
@@ -88,8 +88,8 @@ pkill -f "console-forwarder.cjs --port $PORT --out $TARGET/" 2>/dev/null
|
|
|
88
88
|
# Reap a detached bundler for this exact port when its listener disappeared
|
|
89
89
|
# before the normal stop path. Other ports in the same checkout are separate
|
|
90
90
|
# runtime generations and must remain untouched.
|
|
91
|
-
if [ -f "$
|
|
92
|
-
. "$
|
|
91
|
+
if [ -f "$MOBILE_STOP_DIR/../shared/reap-checkout-metros.sh" ]; then
|
|
92
|
+
. "$MOBILE_STOP_DIR/../shared/reap-checkout-metros.sh"
|
|
93
93
|
reap_checkout_metros_on_port "$TARGET" "$PORT" || true
|
|
94
94
|
fi
|
|
95
95
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Assert that selected Perps orders are open or absent using the Mobile controller.
|
|
2
2
|
import { runAdapter } from '../platform/bridge.mjs';
|
|
3
3
|
import { assertOrders } from './perps.mjs';
|
|
4
|
+
import { useOrderReceipt } from './order-receipt.mjs';
|
|
4
5
|
|
|
5
6
|
function expectedOpen(input) {
|
|
6
7
|
if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
|
|
@@ -10,4 +11,6 @@ function expectedOpen(input) {
|
|
|
10
11
|
throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
runAdapter((input) =>
|
|
14
|
+
runAdapter((input) => Object.hasOwn(input.node ?? {}, 'confirmation')
|
|
15
|
+
? useOrderReceipt(input, 'orders')
|
|
16
|
+
: assertOrders(input, expectedOpen(input)));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Assert that selected Perps positions are open or absent using the Mobile controller.
|
|
2
2
|
import { runAdapter } from '../platform/bridge.mjs';
|
|
3
3
|
import { assertPositions } from './perps.mjs';
|
|
4
|
+
import { useOrderReceipt } from './order-receipt.mjs';
|
|
4
5
|
|
|
5
6
|
function expectedOpen(input) {
|
|
6
7
|
if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
|
|
@@ -10,4 +11,6 @@ function expectedOpen(input) {
|
|
|
10
11
|
throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
runAdapter((input) =>
|
|
14
|
+
runAdapter((input) => Object.hasOwn(input.node ?? {}, 'confirmation')
|
|
15
|
+
? useOrderReceipt(input, 'positions')
|
|
16
|
+
: assertPositions(input, expectedOpen(input)));
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Cancel selected Perps orders through the Mobile controller and assert their absence.
|
|
2
2
|
import { runAdapter } from '../platform/bridge.mjs';
|
|
3
3
|
import { assertOrders, closeOrders } from './perps.mjs';
|
|
4
|
+
import { useOrderReceipt } from './order-receipt.mjs';
|
|
4
5
|
|
|
5
6
|
runAdapter(async (input) => {
|
|
7
|
+
if (Object.hasOwn(input.node ?? {}, 'confirmation')) return useOrderReceipt(input, 'cancel');
|
|
6
8
|
const close = await closeOrders(input);
|
|
7
9
|
const assertion = await assertOrders(input, false);
|
|
8
10
|
return { ...assertion, close };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Close selected Perps positions through the Mobile controller and assert their absence.
|
|
2
2
|
import { runAdapter } from '../platform/bridge.mjs';
|
|
3
3
|
import { assertPositions, closePositions } from './perps.mjs';
|
|
4
|
+
import { useOrderReceipt } from './order-receipt.mjs';
|
|
4
5
|
|
|
5
6
|
runAdapter(async (input) => {
|
|
7
|
+
if (Object.hasOwn(input.node ?? {}, 'confirmation')) return useOrderReceipt(input, 'close');
|
|
6
8
|
const close = await closePositions(input);
|
|
7
9
|
const assertion = await assertPositions(input, false);
|
|
8
10
|
return { ...assertion, close };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Set a visible leverage preset and USD notional, with independent native readback.
|
|
2
|
+
// Requires an already-open instrumented order form; never submits or creates an order.
|
|
3
|
+
import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
|
|
4
|
+
import { observeNativeUi } from '../platform/observe-ui.mjs';
|
|
5
|
+
import { readOrderInputState } from './order-input-state.mjs';
|
|
6
|
+
|
|
7
|
+
async function configureOrder(input) {
|
|
8
|
+
const amount = String(input.node?.amount ?? '');
|
|
9
|
+
const leverage = Number(input.node?.leverage ?? 1);
|
|
10
|
+
if (![1, 2, 3, 5, 10, 20, 40].includes(leverage)) {
|
|
11
|
+
throw new Error('Perps leverage must use a supported visible preset.');
|
|
12
|
+
}
|
|
13
|
+
if (!/^(?:0|[1-9][0-9]{0,8})(?:\.[0-9]{1,2})?$/u.test(amount)
|
|
14
|
+
|| amount.replace('.', '').length > 9 || Number(amount) <= 0) {
|
|
15
|
+
throw new Error('Perps amount requires positive USD notional with at most nine digits and two decimals.');
|
|
16
|
+
}
|
|
17
|
+
const timeout = Number(input.node?.timeout_ms ?? 30000);
|
|
18
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('Perps amount timeout must be positive.');
|
|
19
|
+
const deadline = Date.now() + timeout;
|
|
20
|
+
input = { ...input, node: { ...input.node, _action_deadline_epoch_ms: deadline } };
|
|
21
|
+
const initial = await readOrderInputState(input);
|
|
22
|
+
const observe = async () => {
|
|
23
|
+
if (Date.now() >= deadline) throw new Error('Perps amount input exceeded its deadline.');
|
|
24
|
+
const result = await observeNativeUi({ refs: ['ui.visible'], node: input.node }, input.context);
|
|
25
|
+
const visible = result.observations?.['ui.visible'];
|
|
26
|
+
if (!visible || visible.truncated) throw new Error(`Complete native Perps input observation is unavailable: ${result.warnings?.map((warning) => warning.message).join('; ') || 'truncated hierarchy'}.\nNext: mm-harness call ui.screenshot --adapter mobile`);
|
|
27
|
+
return visible.items ?? [];
|
|
28
|
+
};
|
|
29
|
+
const press = async (testId, long = false) => {
|
|
30
|
+
const result = await bridgeCommand(input, [long ? 'long-press-test-id' : 'press-test-id', testId]);
|
|
31
|
+
if (result?.ok !== true) throw new Error(`Perps input press failed: ${testId}.`);
|
|
32
|
+
};
|
|
33
|
+
const pressText = async (label) => {
|
|
34
|
+
const result = await bridgeCommand(input, ['press-text', label]);
|
|
35
|
+
if (result?.ok !== true) throw new Error(`Perps input press failed: ${label}.`);
|
|
36
|
+
};
|
|
37
|
+
const waitAmount = async (expected, keypadClosed = false) => {
|
|
38
|
+
let lastLabel = '';
|
|
39
|
+
while (Date.now() < deadline) {
|
|
40
|
+
const items = await observe();
|
|
41
|
+
const labels = items.filter((item) => item.test_id === 'perps-amount-display-amount');
|
|
42
|
+
const values = labels.length ? labels : items.filter((item) => item.test_id === 'perps-amount-display-touchable');
|
|
43
|
+
const label = values.length === 1 ? String(values[0].label ?? '').trim() : '';
|
|
44
|
+
lastLabel = label;
|
|
45
|
+
const match = /^\$((?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\.[0-9]{0,2})?)(?:,\s|$)/u.exec(label);
|
|
46
|
+
if (match && Math.round(Number(match[1].replaceAll(',', '')) * 100) === Math.round(expected * 100)
|
|
47
|
+
&& (!keypadClosed || (!items.some((item) => ['perps-order-view-keypad', 'perps-order-view-keypad-done'].includes(item.test_id))
|
|
48
|
+
&& items.some(leverageMatches)))) {
|
|
49
|
+
return label;
|
|
50
|
+
}
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Visible Perps USD amount did not settle at ${expected}; last display was ${JSON.stringify(lastLabel)}.`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const leverageMatches = (item) => {
|
|
57
|
+
if (item.test_id !== 'perps-order-view-leverage-row') return false;
|
|
58
|
+
const value = /(?:^|\s)([1-9][0-9]*)x(?:\s|$)/u.exec(String(item.label ?? '').trim());
|
|
59
|
+
return value !== null && Number(value[1]) === leverage;
|
|
60
|
+
};
|
|
61
|
+
const originalItems = await observe();
|
|
62
|
+
if (Number(initial.form.amount) === Number(amount) && initial.form.leverage === leverage
|
|
63
|
+
&& !originalItems.some((item) => item.test_id === 'perps-order-view-keypad-done')) {
|
|
64
|
+
const observedAmount = await waitAmount(Number(amount), true);
|
|
65
|
+
const final = await readOrderInputState(input, initial.identity);
|
|
66
|
+
if (Number(final.form.amount) !== Number(amount) || final.form.leverage !== leverage) throw new Error('Committed Perps input changed during readback.');
|
|
67
|
+
return { action: input.action, amount, leverage, observedAmount, ...final, changed: false, submitted: false, proofPath: 'perps-input-native-and-mounted-form-readback' };
|
|
68
|
+
}
|
|
69
|
+
if (initial.form.leverage !== leverage) {
|
|
70
|
+
await press('perps-order-view-leverage-row');
|
|
71
|
+
if (leverage === 1) await pressText('1x');
|
|
72
|
+
else await press(`leverage-quick-select-${leverage}`);
|
|
73
|
+
await pressText(`Set ${leverage}x`);
|
|
74
|
+
if ((await readOrderInputState(input, initial.identity)).form.leverage !== leverage) throw new Error('Perps leverage selection did not commit.');
|
|
75
|
+
}
|
|
76
|
+
await press('perps-amount-display-touchable');
|
|
77
|
+
await press('keypad-delete-button', true);
|
|
78
|
+
if (Number((await readOrderInputState(input, initial.identity)).form.amount) !== 0) throw new Error('Perps keypad reset did not clear the amount.');
|
|
79
|
+
for (const digit of amount) {
|
|
80
|
+
await press(digit === '.' ? 'keypad-key-dot' : `keypad-key-${digit}`);
|
|
81
|
+
}
|
|
82
|
+
if (Number((await readOrderInputState(input, initial.identity)).form.amount) !== Number(amount)) throw new Error('Perps keypad input did not match the entered amount.');
|
|
83
|
+
await press('perps-order-view-keypad-done');
|
|
84
|
+
const observedAmount = await waitAmount(Number(amount), true);
|
|
85
|
+
const final = await readOrderInputState(input, initial.identity);
|
|
86
|
+
if (Number(final.form.amount) !== Number(amount) || final.form.leverage !== leverage) throw new Error('Committed Perps amount or leverage differs after Done.');
|
|
87
|
+
return { action: input.action, amount, leverage, observedAmount, ...final, changed: true, submitted: false, proofPath: 'perps-input-native-and-mounted-form-readback' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
runAdapter(configureOrder);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Read the mounted confirmation form and its unchanged unsigned funding request.
|
|
2
|
+
import { bridgeCommand } from '../platform/bridge.mjs';
|
|
3
|
+
|
|
4
|
+
export async function readOrderInputState(input, expectedIdentity) {
|
|
5
|
+
const state = await bridgeCommand(input, ['eval', `(() => {
|
|
6
|
+
const forms = [];
|
|
7
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
8
|
+
function walk(fiber) {
|
|
9
|
+
if (!fiber) return;
|
|
10
|
+
const value = fiber.memoizedProps?.value;
|
|
11
|
+
if (value?.orderForm && typeof value.setAmount === 'function') forms.push(value.orderForm);
|
|
12
|
+
walk(fiber.child);
|
|
13
|
+
walk(fiber.sibling);
|
|
14
|
+
}
|
|
15
|
+
for (const id of hook?.renderers?.keys() ?? []) {
|
|
16
|
+
for (const root of hook.getFiberRoots(id) ?? []) walk(root.current);
|
|
17
|
+
}
|
|
18
|
+
const approvals = Object.values(Engine.context.ApprovalController.state.pendingApprovals);
|
|
19
|
+
const approval = approvals[0];
|
|
20
|
+
const transaction = Engine.context.TransactionController.state.transactions.find(t => t.id === approval?.id);
|
|
21
|
+
const route = __AGENTIC__.getRoute();
|
|
22
|
+
const account = Engine.context.AccountsController.getSelectedAccount();
|
|
23
|
+
const perps = Engine.context.PerpsController.state;
|
|
24
|
+
if (forms.length !== 1 || approvals.length !== 1 || approval?.type !== 'transaction'
|
|
25
|
+
|| transaction?.type !== 'perpsDepositAndOrder' || transaction.status !== 'unapproved' || transaction.hash
|
|
26
|
+
|| route.name !== 'RedesignedConfirmations' || route.params?.asset !== forms[0].asset
|
|
27
|
+
|| route.params?.direction !== forms[0].direction || account.address.toLowerCase() !== transaction.txParams.from.toLowerCase()) {
|
|
28
|
+
throw new Error('Perps input requires one selected unsigned confirmation and one matching mounted form.');
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
identity: { approvalId: approval.id, transactionTime: transaction.time, routeKey: route.key,
|
|
32
|
+
account: account.address, chainId: transaction.chainId, provider: perps.activeProvider,
|
|
33
|
+
isTestnet: perps.isTestnet, asset: forms[0].asset, direction: forms[0].direction },
|
|
34
|
+
form: { amount: forms[0].amount, leverage: forms[0].leverage, orderType: forms[0].type,
|
|
35
|
+
limitPrice: forms[0].limitPrice ?? null,
|
|
36
|
+
takeProfitPrice: forms[0].takeProfitPrice ?? null, stopLossPrice: forms[0].stopLossPrice ?? null }
|
|
37
|
+
};
|
|
38
|
+
})()`]);
|
|
39
|
+
if (!state?.identity || ['approvalId', 'account', 'chainId', 'provider', 'routeKey', 'asset', 'direction']
|
|
40
|
+
.some((key) => typeof state.identity[key] !== 'string' || !state.identity[key])
|
|
41
|
+
|| !Number.isFinite(state.identity.transactionTime) || typeof state.identity.isTestnet !== 'boolean') {
|
|
42
|
+
throw new Error('Perps input confirmation identity is incomplete.');
|
|
43
|
+
}
|
|
44
|
+
if (expectedIdentity && JSON.stringify(state.identity) !== JSON.stringify(expectedIdentity)) {
|
|
45
|
+
throw new Error('Perps input confirmation identity changed.');
|
|
46
|
+
}
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Bind live order results and cleanup to the exact UI approval, request and accepted fills.
|
|
2
|
+
import { bridgeCommand } from '../platform/bridge.mjs';
|
|
3
|
+
|
|
4
|
+
function confirmationFor(input) {
|
|
5
|
+
const confirmation = input.node?.confirmation;
|
|
6
|
+
const identity = confirmation?.identity;
|
|
7
|
+
if (!identity || ['approvalId', 'account', 'provider', 'chainId', 'routeKey', 'asset', 'direction']
|
|
8
|
+
.some((field) => typeof identity[field] !== 'string' || !identity[field])
|
|
9
|
+
|| !Number.isSafeInteger(identity.transactionTime) || identity.isTestnet !== true
|
|
10
|
+
|| identity.provider !== 'hyperliquid' || confirmation.form?.leverage !== 1
|
|
11
|
+
|| !(Number(confirmation.form?.amount) > 0 && Number(confirmation.form.amount) <= 20)) {
|
|
12
|
+
throw new Error('Mobile receipt requires a frozen Hyperliquid testnet confirmation at 1x and at most $20.');
|
|
13
|
+
}
|
|
14
|
+
return confirmation;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function receiptExpression(confirmation, allowMissing = false) {
|
|
18
|
+
return `
|
|
19
|
+
const expected = ${JSON.stringify(confirmation)};
|
|
20
|
+
const controller = Engine.context.PerpsController;
|
|
21
|
+
function assertIdentity() {
|
|
22
|
+
const state = controller.state;
|
|
23
|
+
const account = Engine.context.AccountsController.getSelectedAccount();
|
|
24
|
+
if (account.address.toLowerCase() !== expected.identity.account.toLowerCase()
|
|
25
|
+
|| state.activeProvider !== expected.identity.provider || state.isTestnet !== true) {
|
|
26
|
+
throw new Error('Mobile receipt account, provider or network changed.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
assertIdentity();
|
|
30
|
+
if (typeof __AGENTIC__.readPerpsOrderReceipts !== 'function') throw new Error('Mobile UI receipt observer is unavailable.');
|
|
31
|
+
const receipts = __AGENTIC__.readPerpsOrderReceipts(expected.identity.approvalId);
|
|
32
|
+
if (${allowMissing} && Array.isArray(receipts) && receipts.length === 0) return null;
|
|
33
|
+
if (!Array.isArray(receipts) || receipts.length !== 1) throw new Error('Exactly one UI submission receipt is required.');
|
|
34
|
+
const receipt = receipts[0];
|
|
35
|
+
for (const key of ['approvalId', 'transactionTime', 'account', 'provider', 'isTestnet', 'chainId', 'routeKey']) {
|
|
36
|
+
if (receipt.identity[key] !== expected.identity[key]) throw new Error('UI receipt identity differs from the frozen confirmation.');
|
|
37
|
+
}
|
|
38
|
+
for (const key of ['asset', 'direction']) if (receipt.request[key] !== expected.identity[key]) throw new Error('UI receipt market or direction differs.');
|
|
39
|
+
for (const key of ['amount', 'leverage', 'orderType', 'limitPrice', 'takeProfitPrice', 'stopLossPrice']) {
|
|
40
|
+
if ((receipt.request[key] ?? null) !== (expected.form[key] ?? null)) throw new Error('UI receipt request differs from committed ' + key + '.');
|
|
41
|
+
}
|
|
42
|
+
const ids = [...new Set([receipt.result?.orderId, ...(receipt.result?.childOrderIds ?? [])].filter(id => typeof id === 'string' && id))];
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readOrderReceipt(input) {
|
|
47
|
+
const confirmation = confirmationFor(input);
|
|
48
|
+
const deadline = Date.now() + Number(input.node.timeout_ms ?? 30000);
|
|
49
|
+
input = { ...input, node: { ...input.node, _action_deadline_epoch_ms: deadline } };
|
|
50
|
+
for (;;) {
|
|
51
|
+
const receipt = await bridgeCommand(input, ['eval', `(() => { ${receiptExpression(confirmation, true)} return receipt; })()`]);
|
|
52
|
+
if (receipt?.status === 'settled') {
|
|
53
|
+
const protectionCount = ['takeProfitPrice', 'stopLossPrice'].filter((key) => receipt.request[key] != null).length;
|
|
54
|
+
if (receipt.result?.success === true && protectionCount) {
|
|
55
|
+
if (receipt.request.orderType !== 'market') throw new Error('Protected limit receipts remain unsupported.');
|
|
56
|
+
if (!receipt.protectionResult) {
|
|
57
|
+
if (Date.now() >= deadline) throw new Error('Mobile protection result is unknown; do not submit again.');
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const protectionIds = receipt.protectionResult.childOrderIds;
|
|
62
|
+
if (receipt.protectionResult.success !== true || !Array.isArray(protectionIds)
|
|
63
|
+
|| protectionIds.length !== protectionCount || new Set(protectionIds).size !== protectionCount
|
|
64
|
+
|| protectionIds.some((id) => typeof id !== 'string' || !/^[1-9][0-9]*$/u.test(id)
|
|
65
|
+
|| id === receipt.result.orderId || receipt.result.childOrderIds?.includes(id))) {
|
|
66
|
+
throw new Error(`Mobile protection result lacks complete accepted IDs: ${JSON.stringify(receipt.protectionResult)}.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { action: input.action, receipt };
|
|
70
|
+
}
|
|
71
|
+
if (Date.now() >= deadline) throw new Error('Mobile UI submission result is unknown; do not submit again.');
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function useOrderReceipt(input, operation) {
|
|
77
|
+
const confirmation = confirmationFor(input);
|
|
78
|
+
const deadline = Date.now() + Number(input.node.timeout_ms ?? 30000);
|
|
79
|
+
input = { ...input, node: { ...input.node, _action_deadline_epoch_ms: deadline } };
|
|
80
|
+
if ((input.node.market && input.node.market !== confirmation.identity.asset)
|
|
81
|
+
|| (input.node.side && input.node.side !== confirmation.identity.direction)
|
|
82
|
+
|| (['positions', 'orders'].includes(operation) && input.node.state !== 'open')) {
|
|
83
|
+
throw new Error('Receipt assertion requires the same market/direction and state=open.');
|
|
84
|
+
}
|
|
85
|
+
const mutation = operation === 'cancel' || operation === 'close';
|
|
86
|
+
const result = await bridgeCommand(input, ['eval-async', `(() => {
|
|
87
|
+
${receiptExpression(confirmation)}
|
|
88
|
+
if (receipt.status !== 'settled' || receipt.result?.success !== true || !ids.length) throw new Error('Accepted UI receipt IDs are required before state assertion or cleanup.');
|
|
89
|
+
if (receipt.result.providerId && receipt.result.providerId !== expected.identity.provider) throw new Error('Accepted provider differs from UI receipt identity.');
|
|
90
|
+
if (!['market', 'limit'].includes(receipt.request.orderType)) throw new Error('Receipt cleanup supports ordinary market and limit orders only.');
|
|
91
|
+
const market = receipt.request.asset;
|
|
92
|
+
const protectionKeys = ['takeProfitPrice', 'stopLossPrice'].filter(key => receipt.request[key] != null);
|
|
93
|
+
const protectionIds = protectionKeys.length ? receipt.protectionResult?.childOrderIds : [];
|
|
94
|
+
if (protectionKeys.length && (receipt.request.orderType !== 'market' || receipt.protectionResult?.success !== true
|
|
95
|
+
|| !Array.isArray(protectionIds) || protectionIds.length !== protectionKeys.length
|
|
96
|
+
|| new Set(protectionIds).size !== protectionKeys.length
|
|
97
|
+
|| protectionIds.some(id => typeof id !== 'string' || !/^[1-9][0-9]*$/.test(id) || ids.includes(id)))) {
|
|
98
|
+
throw new Error('Protected cleanup requires complete distinct accepted market protection IDs.');
|
|
99
|
+
}
|
|
100
|
+
const ownedIds = ids.concat(protectionIds);
|
|
101
|
+
const sign = receipt.request.direction === 'long' ? 1n : -1n;
|
|
102
|
+
function decimal(value) {
|
|
103
|
+
const match = /^(-?)(0|[1-9][0-9]{0,19})(?:\\.([0-9]{1,8}))?$/.exec(String(value));
|
|
104
|
+
if (!match) throw new Error('Receipt fill decimal is invalid.');
|
|
105
|
+
return BigInt(match[1] + match[2] + (match[3] ?? '').padEnd(8, '0'));
|
|
106
|
+
}
|
|
107
|
+
function readSnapshot() {
|
|
108
|
+
assertIdentity();
|
|
109
|
+
if (typeof controller.getUserDataSnapshot !== 'function') throw new Error('Complete user-data snapshots are unavailable.');
|
|
110
|
+
return controller.getUserDataSnapshot().then(snapshot => {
|
|
111
|
+
assertIdentity();
|
|
112
|
+
const identity = snapshot?.identity;
|
|
113
|
+
if (!Array.isArray(snapshot?.positions) || !Array.isArray(snapshot?.orders)
|
|
114
|
+
|| typeof identity?.address !== 'string' || identity.address.toLowerCase() !== expected.identity.account.toLowerCase()
|
|
115
|
+
|| identity.provider !== expected.identity.provider || identity.network !== 'testnet') {
|
|
116
|
+
throw new Error('Receipt snapshot is incomplete or belongs to another identity.');
|
|
117
|
+
}
|
|
118
|
+
const orders = snapshot.orders.filter(order => order.symbol === market);
|
|
119
|
+
if (orders.some(order => !ownedIds.includes(String(order.orderId)))
|
|
120
|
+
|| new Set(orders.map(order => String(order.orderId))).size !== orders.length) {
|
|
121
|
+
throw new Error('Extra or unowned market orders prevent receipt assertion or cleanup.');
|
|
122
|
+
}
|
|
123
|
+
return {orders, positions: snapshot.positions.filter(position => position.symbol === market)};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function verifyProtection(position, orders, present) {
|
|
127
|
+
const matchedIds = [];
|
|
128
|
+
for (const [priceKey, ordersKey] of [['takeProfitPrice','takeProfitOrders'],['stopLossPrice','stopLossOrders']]) {
|
|
129
|
+
const wanted = present ? receipt.request[priceKey] : null;
|
|
130
|
+
const triggers = position[ordersKey];
|
|
131
|
+
const count = position[priceKey === 'takeProfitPrice' ? 'takeProfitCount' : 'stopLossCount'];
|
|
132
|
+
if (!Array.isArray(triggers)) throw new Error('Exact position protection arrays are unavailable.');
|
|
133
|
+
if (wanted == null) {
|
|
134
|
+
if (triggers.length || count !== 0 || position[priceKey] != null) throw new Error('Unexpected position protection remains.');
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const direction = priceKey === 'takeProfitPrice' ? 'take_profit' : 'stop';
|
|
138
|
+
const type = direction === 'take_profit' ? 'take_profit_limit' : 'stop_market';
|
|
139
|
+
if (triggers.length !== 1 || count !== 1) throw new Error('Position protection count differs from the frozen UI request.');
|
|
140
|
+
const trigger = triggers[0];
|
|
141
|
+
const order = orders.find(candidate => String(candidate.orderId) === trigger.orderId);
|
|
142
|
+
if (!protectionIds.includes(trigger.orderId) || matchedIds.includes(trigger.orderId)
|
|
143
|
+
|| trigger.direction !== direction || trigger.orderType !== type || decimal(trigger.triggerPrice) !== decimal(wanted)
|
|
144
|
+
|| decimal(trigger.size) !== sign * decimal(position.size) || trigger.reduceOnly !== true || trigger.isPartial !== false
|
|
145
|
+
|| !order || order.side !== (sign === 1n ? 'sell' : 'buy') || order.reduceOnly !== true
|
|
146
|
+
|| order.isTrigger !== true || order.isPositionTpsl !== true || order.triggerOrderType !== type
|
|
147
|
+
|| decimal(order.triggerPrice) !== decimal(wanted) || decimal(order.size) !== decimal(trigger.size)) {
|
|
148
|
+
throw new Error('Exact protection ID, price, size, side or type differs from the UI receipt.');
|
|
149
|
+
}
|
|
150
|
+
matchedIds.push(trigger.orderId);
|
|
151
|
+
}
|
|
152
|
+
if (present && matchedIds.length !== protectionIds.length) throw new Error('Accepted protection IDs are not all represented in the position.');
|
|
153
|
+
}
|
|
154
|
+
function verifyLineage(position) {
|
|
155
|
+
return controller.getOrderFills({aggregateByTime: false, startTime: receipt.identity.transactionTime, endTime: Date.now()}).then(fills => {
|
|
156
|
+
assertIdentity();
|
|
157
|
+
if (!Array.isArray(fills) || fills.length >= 2000) throw new Error('Receipt fill history is incomplete.');
|
|
158
|
+
const marketFills = fills.filter(fill => fill.symbol === market);
|
|
159
|
+
if (!marketFills.length || marketFills.some(fill => !ids.includes(String(fill.orderId)))) throw new Error('Another order changed the market or parent accepted fills are unavailable.');
|
|
160
|
+
marketFills.sort((a,b) => { const difference = sign * (decimal(a.startPosition) - decimal(b.startPosition)); return difference < 0n ? -1 : difference > 0n ? 1 : 0; });
|
|
161
|
+
let size = 0n;
|
|
162
|
+
for (const fill of marketFills) {
|
|
163
|
+
if (fill.side !== (sign === 1n ? 'buy' : 'sell') || decimal(fill.size) <= 0n || decimal(fill.startPosition) !== size) throw new Error('Parent accepted fills do not prove a position starting from zero.');
|
|
164
|
+
size += sign * decimal(fill.size);
|
|
165
|
+
}
|
|
166
|
+
if (size !== decimal(position.size) || position.leverage?.value !== receipt.request.leverage) throw new Error('Current position differs from exact parent fills or leverage.');
|
|
167
|
+
return marketFills;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function cancelOwned(orders, baseline) {
|
|
171
|
+
const results = [];
|
|
172
|
+
function waitForCancellation() {
|
|
173
|
+
return readSnapshot().then(after => {
|
|
174
|
+
if (baseline && (after.positions.length !== 1 || after.positions[0].size !== baseline.size
|
|
175
|
+
|| after.positions[0].entryPrice !== baseline.entryPrice || after.positions[0].leverage?.value !== baseline.leverage.value)) {
|
|
176
|
+
throw new Error('Owned position changed during protective cancellation.');
|
|
177
|
+
}
|
|
178
|
+
if (!after.orders.length) {
|
|
179
|
+
if (baseline) verifyProtection(after.positions[0], after.orders, false);
|
|
180
|
+
return {after, results};
|
|
181
|
+
}
|
|
182
|
+
if (Date.now() >= ${deadline}) throw new Error('Accepted order cancellation is not independently confirmed.');
|
|
183
|
+
return new Promise(resolve => setTimeout(resolve, 250)).then(waitForCancellation);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return orders.reduce((previous, order) => previous.then(() => {
|
|
187
|
+
if (Date.now() >= ${deadline}) throw new Error('Receipt cleanup deadline elapsed before cancellation.');
|
|
188
|
+
assertIdentity();
|
|
189
|
+
return controller.cancelOrder({symbol: market, orderId: String(order.orderId)}).then(result => {
|
|
190
|
+
results.push({orderId: order.orderId, result});
|
|
191
|
+
if (result?.success !== true) throw new Error('Exact receipt cancellation failed: ' + JSON.stringify(results));
|
|
192
|
+
});
|
|
193
|
+
}), Promise.resolve()).then(waitForCancellation);
|
|
194
|
+
}
|
|
195
|
+
return readSnapshot().then(snapshot => {
|
|
196
|
+
const orders = snapshot.orders;
|
|
197
|
+
if (${JSON.stringify(operation)} === 'orders') {
|
|
198
|
+
if (!orders.length || orders.some(order => !ids.includes(String(order.orderId))
|
|
199
|
+
|| order.side !== (sign === 1n ? 'buy' : 'sell'))) throw new Error('No matching exact accepted UI order remains open.');
|
|
200
|
+
return {receipt, orders};
|
|
201
|
+
}
|
|
202
|
+
if (${JSON.stringify(operation)} === 'cancel' && !protectionKeys.length) {
|
|
203
|
+
return cancelOwned(orders).then(({results}) => ({receipt, results, remainingOwnedOrders: 0}));
|
|
204
|
+
}
|
|
205
|
+
if (!snapshot.positions.length && ${JSON.stringify(operation)} === 'close' && !orders.length) return {receipt, closed: false, alreadyAbsent: true};
|
|
206
|
+
if (snapshot.positions.length !== 1) throw new Error('Receipt position is absent or ambiguous.');
|
|
207
|
+
const position = snapshot.positions[0];
|
|
208
|
+
if (protectionKeys.length && orders.some(order => !protectionIds.includes(String(order.orderId)))) throw new Error('Extra parent orders prevent protected position assertion or cleanup.');
|
|
209
|
+
return verifyLineage(position).then(fills => {
|
|
210
|
+
const protectionPresent = protectionIds.some(id => orders.some(order => String(order.orderId) === id));
|
|
211
|
+
verifyProtection(position, orders, ${JSON.stringify(operation)} === 'positions' || protectionPresent);
|
|
212
|
+
if (${JSON.stringify(operation)} === 'positions') return {receipt, position, fills};
|
|
213
|
+
if (orders.some(order => !protectionIds.includes(String(order.orderId)))) throw new Error('Parent order remainder prevents owned position cleanup.');
|
|
214
|
+
return cancelOwned(orders, position).then(({after, results}) => {
|
|
215
|
+
if (${JSON.stringify(operation)} === 'cancel') return {receipt, results, remainingOwnedOrders: 0};
|
|
216
|
+
const unchanged = after.positions[0];
|
|
217
|
+
return verifyLineage(unchanged).then(readSnapshot).then(beforeClose => {
|
|
218
|
+
if (beforeClose.positions.length !== 1 || beforeClose.orders.length
|
|
219
|
+
|| beforeClose.positions[0].size !== unchanged.size || beforeClose.positions[0].entryPrice !== unchanged.entryPrice
|
|
220
|
+
|| beforeClose.positions[0].leverage?.value !== unchanged.leverage.value) throw new Error('Owned position changed before close.');
|
|
221
|
+
verifyProtection(beforeClose.positions[0], beforeClose.orders, false);
|
|
222
|
+
if (Date.now() >= ${deadline}) throw new Error('Receipt cleanup deadline elapsed before close.');
|
|
223
|
+
assertIdentity();
|
|
224
|
+
return controller.closePosition({symbol: market, size: String(sign === 1n ? unchanged.size : String(unchanged.size).slice(1)), orderType: 'market', providerId: 'hyperliquid', position: unchanged}).then(closeResult => {
|
|
225
|
+
if (closeResult?.success !== true || !closeResult.orderId) throw new Error('Receipt-owned close has no accepted result: ' + JSON.stringify(closeResult));
|
|
226
|
+
function waitForClose() {
|
|
227
|
+
return readSnapshot().then(final => {
|
|
228
|
+
if (!final.positions.length && !final.orders.length) return {receipt, results, closeResult, closed: true};
|
|
229
|
+
if (Date.now() >= ${deadline}) throw new Error('Receipt-owned close is not independently confirmed.');
|
|
230
|
+
return new Promise(resolve => setTimeout(resolve, 250)).then(waitForClose);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return waitForClose();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
})()`]);
|
|
240
|
+
return { action: input.action, ...result, mutation };
|
|
241
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Freeze the visible unsigned order inputs and refuse a repeated or unobservable submission.
|
|
2
|
+
import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
|
|
3
|
+
import { readOrderInputState } from './order-input-state.mjs';
|
|
4
|
+
import { observeNativeUi } from '../platform/observe-ui.mjs';
|
|
5
|
+
|
|
6
|
+
runAdapter(async (input) => {
|
|
7
|
+
const confirmation = await readOrderInputState(input, input.node?.identity);
|
|
8
|
+
const { identity, form } = confirmation;
|
|
9
|
+
const observation = await observeNativeUi({ refs: ['ui.visible'], node: input.node }, input.context);
|
|
10
|
+
const visible = observation.observations?.['ui.visible'];
|
|
11
|
+
const payRows = visible?.items?.filter((item) => item.test_id === 'pay-with') ?? [];
|
|
12
|
+
if (!visible || visible.truncated || payRows.length !== 1 || payRows[0].label !== 'Pay with, Perps balance') {
|
|
13
|
+
throw new Error('Receipt proof requires the visible Perps balance payment source; funding is not permitted.');
|
|
14
|
+
}
|
|
15
|
+
if (identity.provider !== 'hyperliquid' || identity.isTestnet !== true
|
|
16
|
+
|| form.leverage !== 1 || !(Number(form.amount) > 0 && Number(form.amount) <= 20)
|
|
17
|
+
|| !['market', 'limit'].includes(form.orderType)) {
|
|
18
|
+
throw new Error('Mobile receipt proof requires Hyperliquid testnet, 1x and at most $20 on a market or limit order.');
|
|
19
|
+
}
|
|
20
|
+
if (Number(form.amount) !== Number(input.node.amount) || form.leverage !== Number(input.node.leverage)) {
|
|
21
|
+
throw new Error('Committed Mobile order inputs differ from the requested amount or leverage.');
|
|
22
|
+
}
|
|
23
|
+
const protection = input.node.protection ?? 'none';
|
|
24
|
+
for (const [field, required] of [['takeProfitPrice', ['take_profit', 'both'].includes(protection)], ['stopLossPrice', ['stop_loss', 'both'].includes(protection)]]) {
|
|
25
|
+
if (required ? !(Number(form[field]) > 0) : form[field] !== null) throw new Error(`Committed Mobile ${field} does not match requested protection.`);
|
|
26
|
+
}
|
|
27
|
+
const prior = await bridgeCommand(input, ['eval', `(() => {
|
|
28
|
+
if (typeof __AGENTIC__.readPerpsOrderReceipts !== 'function') throw new Error('Mobile UI receipt observer is unavailable.');
|
|
29
|
+
return __AGENTIC__.readPerpsOrderReceipts(${JSON.stringify(identity.approvalId)});
|
|
30
|
+
})()`]);
|
|
31
|
+
if (!Array.isArray(prior) || prior.length) throw new Error('This unsigned confirmation already has a submission attempt; do not submit it again.');
|
|
32
|
+
return { action: input.action, confirmation, submitted: false };
|
|
33
|
+
});
|
|
@@ -2399,6 +2399,7 @@
|
|
|
2399
2399
|
"schema": {
|
|
2400
2400
|
"type": "object",
|
|
2401
2401
|
"properties": {
|
|
2402
|
+
"confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
|
|
2402
2403
|
"market": {
|
|
2403
2404
|
"type": "string",
|
|
2404
2405
|
"description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
|
|
@@ -2490,6 +2491,7 @@
|
|
|
2490
2491
|
"schema": {
|
|
2491
2492
|
"type": "object",
|
|
2492
2493
|
"properties": {
|
|
2494
|
+
"confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
|
|
2493
2495
|
"market": {
|
|
2494
2496
|
"type": "string",
|
|
2495
2497
|
"description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
|
|
@@ -2576,6 +2578,53 @@
|
|
|
2576
2578
|
],
|
|
2577
2579
|
"execution_capabilities": ["app-mutation", "external-mutation"]
|
|
2578
2580
|
},
|
|
2581
|
+
"metamask.perps.read_order_input": {
|
|
2582
|
+
"description": "Freeze committed unsigned Mobile inputs and require a receipt observer before UI submission.",
|
|
2583
|
+
"examples": [{ "action": "metamask.perps.read_order_input", "amount": "10", "leverage": 1, "protection": "none", "intent": "Freeze the exact unsigned order before its UI submission", "next": "submit-order" }],
|
|
2584
|
+
"schema": {
|
|
2585
|
+
"type": "object", "required": ["amount", "leverage"], "additionalProperties": false,
|
|
2586
|
+
"properties": {
|
|
2587
|
+
"amount": { "type": ["string", "number"] },
|
|
2588
|
+
"leverage": { "type": "number" },
|
|
2589
|
+
"identity": { "type": "object" },
|
|
2590
|
+
"protection": { "type": "string", "enum": ["none", "take_profit", "stop_loss", "both"], "default": "none" }
|
|
2591
|
+
}
|
|
2592
|
+
},
|
|
2593
|
+
"execution_capabilities": []
|
|
2594
|
+
},
|
|
2595
|
+
"metamask.perps.read_order_receipt": {
|
|
2596
|
+
"description": "Observe the actual Mobile UI submission result including failure and accepted IDs; never dispatch.",
|
|
2597
|
+
"examples": [{ "action": "metamask.perps.read_order_receipt", "confirmation": "{{outputs.capture-input.confirmation}}", "intent": "Observe the result of the frozen UI submission", "next": "done" }],
|
|
2598
|
+
"schema": {
|
|
2599
|
+
"type": "object", "required": ["confirmation"], "additionalProperties": false,
|
|
2600
|
+
"properties": {
|
|
2601
|
+
"confirmation": { "type": "object" },
|
|
2602
|
+
"timeout_ms": { "type": "number", "minimum": 1, "default": 30000 }
|
|
2603
|
+
}
|
|
2604
|
+
},
|
|
2605
|
+
"execution_capabilities": []
|
|
2606
|
+
},
|
|
2607
|
+
"metamask.perps.configure_order": {
|
|
2608
|
+
"description": "Set USD notional and visible leverage on an already-open instrumented Mobile confirmation; verify exact mounted values and native readback against its unchanged unsigned request, without submitting.",
|
|
2609
|
+
"schema": {
|
|
2610
|
+
"type": "object",
|
|
2611
|
+
"required": ["amount"],
|
|
2612
|
+
"properties": {
|
|
2613
|
+
"amount": { "type": ["string", "number"] },
|
|
2614
|
+
"leverage": { "type": "integer", "enum": [1, 2, 3, 5, 10, 20, 40], "default": 1 },
|
|
2615
|
+
"timeout_ms": { "type": "number", "minimum": 1, "default": 30000 }
|
|
2616
|
+
},
|
|
2617
|
+
"additionalProperties": false
|
|
2618
|
+
},
|
|
2619
|
+
"examples": [{
|
|
2620
|
+
"action": "metamask.perps.configure_order",
|
|
2621
|
+
"amount": "10",
|
|
2622
|
+
"leverage": 1,
|
|
2623
|
+
"intent": "Prepare the requested USD notional without submitting",
|
|
2624
|
+
"next": "done"
|
|
2625
|
+
}],
|
|
2626
|
+
"execution_capabilities": ["app-mutation"]
|
|
2627
|
+
},
|
|
2579
2628
|
"metamask.perps.place_order": {
|
|
2580
2629
|
"description": "mobile Place a Perps order through a supported app/API path. Default to testnet; mainnet order placement requires an explicit user request.",
|
|
2581
2630
|
"schema": {
|
|
@@ -2685,6 +2734,7 @@
|
|
|
2685
2734
|
"schema": {
|
|
2686
2735
|
"type": "object",
|
|
2687
2736
|
"properties": {
|
|
2737
|
+
"confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
|
|
2688
2738
|
"market": {
|
|
2689
2739
|
"type": "string",
|
|
2690
2740
|
"description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
|
|
@@ -2777,6 +2827,7 @@
|
|
|
2777
2827
|
"schema": {
|
|
2778
2828
|
"type": "object",
|
|
2779
2829
|
"properties": {
|
|
2830
|
+
"confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
|
|
2780
2831
|
"market": {
|
|
2781
2832
|
"type": "string",
|
|
2782
2833
|
"description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
|