@deeeed/metamask-harness 0.87.0 → 0.89.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 +33 -0
- package/adapters/extension/ensure-browser.sh +6 -10
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +31 -4
- package/adapters/extension/profile-extension-state.cjs +156 -0
- package/adapters/extension/readiness.mjs +21 -2
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/wait-for-bridge.cjs +9 -3
- package/dist/adapters/extension/actions.js +2 -1
- package/dist/adapters/extension/run.js +6 -0
- package/dist/adapters/mobile/android-screenrecord.js +10 -6
- package/dist/adapters/mobile/recording-outcome.js +2 -51
- package/dist/adapters/mobile/run.js +10 -4
- package/dist/adapters/perps-mainnet-cleanup.js +122 -0
- package/dist/engine.js +11 -2
- package/dist/funding-execution-context.js +13 -8
- package/dist/host.js +6 -2
- package/dist/index.js +3 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/provider.js +74 -0
- package/dist/recipe-security.js +1 -0
- package/docs/CONTRIBUTING.md +7 -0
- package/docs/RECIPES.md +38 -13
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
- package/docs/cli-contract.md +1 -1
- package/library/actions/extension/perps/mutation-receipt.mjs +4 -3
- package/library/actions/extension/perps/perps.mjs +200 -9
- package/library/actions/extension/perps/set_network.mjs +6 -0
- package/library/actions/mobile/perps/perps.mjs +159 -11
- package/library/actions/mobile/perps/place_order.mjs +7 -4
- package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
- package/library/actions/shared/perps/order-policy.mjs +56 -19
- package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
- package/library/manifests/extension.action-manifest.json +34 -4
- package/library/manifests/mobile.action-manifest.json +18 -5
- package/package.json +14 -9
- package/scripts/validate-release-report.mjs +14 -2
- package/site/assets/help-recipes.json +1 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// The mainnet limit orders a run opened and has not yet proven cancelled. The
|
|
2
|
+
// ledger sits next to the checkout's wallet fixture, so every process of a run
|
|
3
|
+
// sees it: the action children that place and cancel, and the run's teardown
|
|
4
|
+
// and abort handler (src/adapters/perps-mainnet-cleanup.ts) that cancel what is
|
|
5
|
+
// left. place_order writes a pending entry before dispatch and the order ID
|
|
6
|
+
// after it; on mainnet close_orders cancels only orders listed here. Settling an
|
|
7
|
+
// entry writes order ID, cancel result and the read proving it gone into the
|
|
8
|
+
// placing run's artifacts (MAINNET_ORDER_EVIDENCE_FILE). Every write holds a
|
|
9
|
+
// cross-process lock, and each entry names its run (the mm-harness process the
|
|
10
|
+
// action descends from), so a run's cleanup leaves another live run's orders
|
|
11
|
+
// alone. Whether an order is gone is read from the venue itself
|
|
12
|
+
// (readHyperliquidVenue), never only from the app, whose reads can come back
|
|
13
|
+
// empty on an outage or after a provider or account switch.
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { mkdir, readFile, rename, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
21
|
+
|
|
22
|
+
export const MAINNET_RUN_OWNER_ENV = 'RECIPE_RUN_OWNER_PID';
|
|
23
|
+
export const MAINNET_ORDER_EVIDENCE_FILE = 'perps-mainnet-orders.json';
|
|
24
|
+
// The only venue a mainnet limit order may use: its public info API proves the result.
|
|
25
|
+
export const MAINNET_LIMIT_PROVIDER = 'hyperliquid';
|
|
26
|
+
const HYPERLIQUID_INFO_URL = 'https://api.hyperliquid.xyz/info';
|
|
27
|
+
|
|
28
|
+
export function mainnetOrderLedgerPath(projectRoot) {
|
|
29
|
+
return path.join(path.dirname(walletFixturePath(projectRoot)), 'perps-mainnet-orders.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readJson(file, empty) {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = await readFile(file, 'utf8');
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// No file is the empty ledger (or evidence list); anything else throws.
|
|
38
|
+
if (error?.code === 'ENOENT') return empty;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writeJson(file, value) {
|
|
45
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
46
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
47
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
+
await rename(temp, file);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @returns {Promise<{ entries: object[], networkSwitch?: object, abortingRuns?: number[] }>} */
|
|
52
|
+
export async function readMainnetOrderLedger(projectRoot) {
|
|
53
|
+
const file = mainnetOrderLedgerPath(projectRoot);
|
|
54
|
+
const ledger = await readJson(file, { entries: [] });
|
|
55
|
+
if (!Array.isArray(ledger?.entries)) throw new Error(`${file} is not a Perps mainnet order ledger.`);
|
|
56
|
+
return ledger;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LOCK_STALE_MS = 30_000;
|
|
60
|
+
const LOCK_WAIT_MS = 15_000;
|
|
61
|
+
|
|
62
|
+
// mkdir is atomic: whoever creates the directory holds the lock. A lock older
|
|
63
|
+
// than LOCK_STALE_MS belonged to a process that died holding it.
|
|
64
|
+
async function withLedgerLock(projectRoot, body) {
|
|
65
|
+
const lock = `${mainnetOrderLedgerPath(projectRoot)}.lock`;
|
|
66
|
+
await mkdir(path.dirname(lock), { recursive: true });
|
|
67
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
68
|
+
for (;;) {
|
|
69
|
+
try {
|
|
70
|
+
await mkdir(lock);
|
|
71
|
+
break;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
74
|
+
const held = await stat(lock).then((info) => Date.now() - info.mtimeMs, (statError) => {
|
|
75
|
+
// Released between mkdir and stat: try again at once.
|
|
76
|
+
if (statError?.code === 'ENOENT') return 0;
|
|
77
|
+
throw statError;
|
|
78
|
+
});
|
|
79
|
+
if (held > LOCK_STALE_MS) {
|
|
80
|
+
await rmdir(lock).catch((rmError) => {
|
|
81
|
+
// Another waiter removed the stale lock first.
|
|
82
|
+
if (rmError?.code !== 'ENOENT') throw rmError;
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (Date.now() >= deadline) throw new Error(`Mainnet order ledger is locked (${lock}) for over ${LOCK_WAIT_MS / 1000} s.`);
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return await body();
|
|
92
|
+
} finally {
|
|
93
|
+
await rmdir(lock);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function updateLedger(projectRoot, change) {
|
|
98
|
+
return withLedgerLock(projectRoot, async () => {
|
|
99
|
+
const ledger = await readMainnetOrderLedger(projectRoot);
|
|
100
|
+
const result = change(ledger);
|
|
101
|
+
await writeJson(mainnetOrderLedgerPath(projectRoot), ledger);
|
|
102
|
+
return result;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An explicitly registered provider process must be this process or an ancestor.
|
|
107
|
+
// Existing mm-harness children also resolve their nearest CLI ancestor.
|
|
108
|
+
export function currentRunPid() {
|
|
109
|
+
const owner = Number(process.env[MAINNET_RUN_OWNER_ENV]);
|
|
110
|
+
if (Number.isSafeInteger(owner) && owner === process.pid) return owner;
|
|
111
|
+
let pid = process.ppid;
|
|
112
|
+
for (let depth = 0; depth < 8 && pid > 1; depth += 1) {
|
|
113
|
+
const [parent, ...args] = execFileSync('ps', ['-o', 'ppid=,args=', '-p', String(pid)], { encoding: 'utf8' }).trim().split(/\s+/u);
|
|
114
|
+
if (Number.isSafeInteger(owner) && owner === pid) return pid;
|
|
115
|
+
if (args.some((arg) => /mm-harness-cli\.(?:js|ts)$/u.test(arg))) return pid;
|
|
116
|
+
pid = Number(parent);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function venueInfo(body) {
|
|
122
|
+
const response = await fetch(HYPERLIQUID_INFO_URL, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
signal: AbortSignal.timeout(10_000),
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) throw new Error(`Hyperliquid info ${body.type} answered HTTP ${response.status}`);
|
|
129
|
+
return response.json();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The account's open orders and signed position in one market, read from the
|
|
134
|
+
* Hyperliquid mainnet API (main dex markets only). Throws when it cannot read.
|
|
135
|
+
*/
|
|
136
|
+
export async function readHyperliquidVenue(address, symbol) {
|
|
137
|
+
if (String(symbol).includes(':')) throw new Error(`${symbol} is not a main-dex Hyperliquid market; its book cannot be verified`);
|
|
138
|
+
const [orders, state] = await Promise.all([
|
|
139
|
+
venueInfo({ type: 'frontendOpenOrders', user: address }),
|
|
140
|
+
venueInfo({ type: 'clearinghouseState', user: address }),
|
|
141
|
+
]);
|
|
142
|
+
if (!Array.isArray(orders) || !Array.isArray(state?.assetPositions)) throw new Error('Hyperliquid info returned an unexpected shape');
|
|
143
|
+
return {
|
|
144
|
+
source: 'api.hyperliquid.xyz/info frontendOpenOrders+clearinghouseState',
|
|
145
|
+
openOrders: orders.map((order) => ({
|
|
146
|
+
orderId: String(order.oid),
|
|
147
|
+
symbol: order.coin,
|
|
148
|
+
side: order.side === 'B' ? 'long' : 'short',
|
|
149
|
+
price: order.limitPx,
|
|
150
|
+
})),
|
|
151
|
+
positionSize: state.assetPositions
|
|
152
|
+
.filter((entry) => entry.position?.coin === symbol)
|
|
153
|
+
.reduce((total, entry) => total + Number(entry.position.szi), 0),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The venue's fields for ledgerOrderTargets.
|
|
158
|
+
export const VENUE_ORDER_FIELDS = {
|
|
159
|
+
idOf: (order) => order.orderId,
|
|
160
|
+
symbolOf: (order) => order.symbol,
|
|
161
|
+
sideOf: (order) => order.side,
|
|
162
|
+
priceOf: (order) => order.price,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function processAlive(pid) {
|
|
166
|
+
try {
|
|
167
|
+
process.kill(pid, 0);
|
|
168
|
+
return true;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// ESRCH: gone. EPERM: alive under another user.
|
|
171
|
+
if (error?.code === 'ESRCH') return false;
|
|
172
|
+
if (error?.code === 'EPERM') return true;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Whether `runPid` (the process cleaning up) may settle this record: its own, or one whose run is gone. */
|
|
178
|
+
export function cleanupOwns(record, runPid) {
|
|
179
|
+
return !record.runPid || record.runPid === runPid || !processAlive(record.runPid);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Record a mainnet limit order before dispatch, so a run that dies before the
|
|
184
|
+
* venue answers still cancels it. `baselineOrderIds` are the account's open
|
|
185
|
+
* order IDs before dispatch; the venue's position in the market is recorded
|
|
186
|
+
* too. Refused once the run is aborting, or off Hyperliquid.
|
|
187
|
+
* `connection` is what reaches the app again from the run's process.
|
|
188
|
+
* @returns {Promise<string>} The entry ID.
|
|
189
|
+
*/
|
|
190
|
+
export async function recordPendingMainnetOrder(projectRoot, {
|
|
191
|
+
platform, provider, symbol, side, limitPrice, address, baselineOrderIds, artifactsDir, connection,
|
|
192
|
+
}) {
|
|
193
|
+
if (provider !== MAINNET_LIMIT_PROVIDER) {
|
|
194
|
+
throw Object.assign(
|
|
195
|
+
new Error(`PERPS_MAINNET_ORDER_REFUSED: a mainnet limit order needs the ${MAINNET_LIMIT_PROVIDER} provider, not ${provider ?? 'an unknown one'}; refused before dispatch.`),
|
|
196
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED', knownNotDispatched: true },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
// The run is resolved before anything is awaited: an aborted run's child,
|
|
200
|
+
// re-parented after its run exits, must still find it marked aborting.
|
|
201
|
+
const runPid = currentRunPid();
|
|
202
|
+
if (!runPid) {
|
|
203
|
+
throw Object.assign(
|
|
204
|
+
new Error('PERPS_MAINNET_ORDER_REFUSED: a mainnet limit order needs an mm-harness run or call to cancel it at its end; refused before dispatch.'),
|
|
205
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED', knownNotDispatched: true },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// The venue's book before the order: a fill shows as a position change, and
|
|
209
|
+
// a recovered pending order is never one that was already open.
|
|
210
|
+
const venue = await readHyperliquidVenue(address, symbol);
|
|
211
|
+
const id = randomUUID();
|
|
212
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
213
|
+
if (ledger.abortingRuns?.includes(runPid)) throw runAbortingRefusal();
|
|
214
|
+
ledger.entries.push({
|
|
215
|
+
id,
|
|
216
|
+
platform,
|
|
217
|
+
provider,
|
|
218
|
+
state: 'pending',
|
|
219
|
+
orderId: null,
|
|
220
|
+
runPid,
|
|
221
|
+
symbol,
|
|
222
|
+
side,
|
|
223
|
+
limitPrice: String(limitPrice),
|
|
224
|
+
address: String(address).toLowerCase(),
|
|
225
|
+
baselineOrderIds: [...new Set([...baselineOrderIds.map(String), ...venue.openOrders.map((order) => order.orderId)])],
|
|
226
|
+
baselinePositionSize: venue.positionSize,
|
|
227
|
+
artifactsDir,
|
|
228
|
+
connection,
|
|
229
|
+
placedAt: new Date().toISOString(),
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
return id;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function runAbortingRefusal() {
|
|
236
|
+
return Object.assign(
|
|
237
|
+
new Error('PERPS_MAINNET_ORDER_REFUSED: the run is aborting; mainnet order not dispatched.'),
|
|
238
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED', knownNotDispatched: true },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A redispatch reuses its pending entry: refused once the entry's run is aborting
|
|
243
|
+
// or its cleanup has already settled the entry, as a first dispatch would be.
|
|
244
|
+
export async function requirePendingMainnetOrder(projectRoot, id) {
|
|
245
|
+
await withLedgerLock(projectRoot, async () => {
|
|
246
|
+
const ledger = await readMainnetOrderLedger(projectRoot);
|
|
247
|
+
const entry = ledger.entries.find((candidate) => candidate.id === id);
|
|
248
|
+
if (!entry || ledger.abortingRuns?.includes(entry.runPid)) throw runAbortingRefusal();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function recordPlacedMainnetOrder(projectRoot, id, orderId) {
|
|
253
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
254
|
+
const entry = ledger.entries.find((candidate) => candidate.id === id);
|
|
255
|
+
if (!entry) throw new Error(`Mainnet order ledger has no entry ${id}; the order ${orderId} is not tracked.`);
|
|
256
|
+
entry.state = 'placed';
|
|
257
|
+
entry.orderId = String(orderId);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The order was refused before dispatch: nothing to cancel.
|
|
262
|
+
export async function dropMainnetOrder(projectRoot, id) {
|
|
263
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
264
|
+
ledger.entries = ledger.entries.filter((candidate) => candidate.id !== id);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The ledger entry of a placed, still open order this checkout's runs opened, or null. */
|
|
269
|
+
export async function ownedMainnetOrder(projectRoot, orderId) {
|
|
270
|
+
const wanted = String(orderId ?? '');
|
|
271
|
+
// No order ID or no checkout: nothing this checkout's runs can own.
|
|
272
|
+
if (!wanted || !projectRoot) return null;
|
|
273
|
+
const { entries } = await readMainnetOrderLedger(projectRoot);
|
|
274
|
+
return entries.find((entry) => entry.state === 'placed' && entry.orderId === wanted) ?? null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// close_orders cancelled it: the run's teardown still checks the market's
|
|
278
|
+
// position (a partial fill) before settling the entry with this proof.
|
|
279
|
+
export async function markMainnetOrderCancelled(projectRoot, id, cancelProof) {
|
|
280
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
281
|
+
const entry = ledger.entries.find((candidate) => candidate.id === id);
|
|
282
|
+
if (!entry) throw new Error(`Mainnet order ledger has no entry ${id}.`);
|
|
283
|
+
entry.state = 'cancelled';
|
|
284
|
+
entry.cancelProof = cancelProof;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const LIMIT_PRICE_TOLERANCE = 0.001;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The open orders a ledger entry stands for: its order by ID, or for a pending
|
|
292
|
+
* entry an order in its market and side at its limit price that was not open
|
|
293
|
+
* before dispatch.
|
|
294
|
+
*/
|
|
295
|
+
export function ledgerOrderTargets(entry, openOrders, { idOf, symbolOf, sideOf, priceOf }) {
|
|
296
|
+
if (entry.orderId) return openOrders.filter((order) => String(idOf(order)) === entry.orderId);
|
|
297
|
+
const baseline = new Set(entry.baselineOrderIds);
|
|
298
|
+
const limitPrice = Number(entry.limitPrice);
|
|
299
|
+
return openOrders.filter((order) => symbolOf(order) === entry.symbol
|
|
300
|
+
&& sideOf(order) === entry.side
|
|
301
|
+
&& !baseline.has(String(idOf(order)))
|
|
302
|
+
&& Math.abs(Number(priceOf(order)) - limitPrice) <= limitPrice * LIMIT_PRICE_TOLERANCE);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Drop a cancelled entry and append its proof to the placing run's evidence.
|
|
307
|
+
* @param {object} proof - { via, canceled: [{ orderId, result }], read: { source, openOrderIds, positionSize, absent | notOpen } }
|
|
308
|
+
*/
|
|
309
|
+
export async function settleMainnetOrder(projectRoot, entry, proof) {
|
|
310
|
+
const evidencePath = path.join(entry.artifactsDir, MAINNET_ORDER_EVIDENCE_FILE);
|
|
311
|
+
await withLedgerLock(projectRoot, async () => {
|
|
312
|
+
const evidence = await readJson(evidencePath, { orders: [] });
|
|
313
|
+
evidence.orders.push({
|
|
314
|
+
// A pending entry learns its order ID from the cancel that found it.
|
|
315
|
+
orderId: entry.orderId ?? proof.canceled?.[0]?.orderId ?? null,
|
|
316
|
+
symbol: entry.symbol,
|
|
317
|
+
side: entry.side,
|
|
318
|
+
limitPrice: entry.limitPrice,
|
|
319
|
+
placedAt: entry.placedAt,
|
|
320
|
+
settledAt: new Date().toISOString(),
|
|
321
|
+
...proof,
|
|
322
|
+
});
|
|
323
|
+
await writeJson(evidencePath, evidence);
|
|
324
|
+
const ledger = await readMainnetOrderLedger(projectRoot);
|
|
325
|
+
ledger.entries = ledger.entries.filter((candidate) => candidate.id !== entry.id);
|
|
326
|
+
await writeJson(mainnetOrderLedgerPath(projectRoot), ledger);
|
|
327
|
+
});
|
|
328
|
+
return evidencePath;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// From now on this run's action children dispatch no mainnet order.
|
|
332
|
+
export async function markRunAborting(projectRoot, runPid) {
|
|
333
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
334
|
+
ledger.abortingRuns = [...(ledger.abortingRuns ?? []).filter(processAlive), runPid];
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The Extension's set_network switched Perps to mainnet for this run: the run's
|
|
339
|
+
// teardown switches it back to testnet.
|
|
340
|
+
export async function recordMainnetSwitch(projectRoot, networkSwitch) {
|
|
341
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
342
|
+
ledger.networkSwitch = { ...networkSwitch, runPid: currentRunPid(), at: new Date().toISOString() };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function clearMainnetSwitch(projectRoot) {
|
|
347
|
+
await updateLedger(projectRoot, (ledger) => {
|
|
348
|
+
delete ledger.networkSwitch;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Leaving mainnet would strand the run's open orders: the forced cancel needs
|
|
353
|
+
// the app on mainnet. Cancel them (close_orders) or let the run end first.
|
|
354
|
+
export async function requireNoOpenMainnetOrders(projectRoot, action) {
|
|
355
|
+
if (!projectRoot) return;
|
|
356
|
+
const { entries } = await readMainnetOrderLedger(projectRoot);
|
|
357
|
+
const open = entries.filter((entry) => entry.state !== 'cancelled');
|
|
358
|
+
if (open.length === 0) return;
|
|
359
|
+
throw Object.assign(
|
|
360
|
+
new Error(
|
|
361
|
+
`PERPS_MAINNET_ORDER_OPEN: ${action} would leave mainnet while the run's mainnet order(s) ` +
|
|
362
|
+
`${open.map((entry) => entry.orderId ?? `pending ${entry.symbol}`).join(', ')} are open. ` +
|
|
363
|
+
'Next: cancel them with metamask.perps.close_orders first, or switch networks after the run ends.',
|
|
364
|
+
),
|
|
365
|
+
{ code: 'PERPS_MAINNET_ORDER_OPEN' },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function mainnetOrderLeftOpenError(failures) {
|
|
370
|
+
return Object.assign(
|
|
371
|
+
new Error(
|
|
372
|
+
`PERPS_MAINNET_ORDER_LEFT_OPEN: ${failures.length} mainnet order(s) this checkout's runs placed are not proven closed out: ` +
|
|
373
|
+
`${failures.map(({ entry, error }) => `${entry.orderId ?? `pending ${entry.symbol} ${entry.side} @ ${entry.limitPrice}`} (${error instanceof Error ? error.message : String(error)})`).join('; ')}. ` +
|
|
374
|
+
'Next: cancel the order or close the position in the app on the account that placed it now; the next run on this checkout retries an order cancel while the app is on that account and mainnet.',
|
|
375
|
+
),
|
|
376
|
+
{ code: 'PERPS_MAINNET_ORDER_LEFT_OPEN' },
|
|
377
|
+
);
|
|
378
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// The Perps order rule every adapter applies before dispatch (F21).
|
|
2
2
|
// Testnet orders need no funding proof. A mainnet order is allowed only from a
|
|
3
3
|
// synced fixture account within the house limits: 1x isolated, notional at
|
|
4
|
-
// most $15, closed again inside the same action
|
|
4
|
+
// most $15, and either a market order closed again inside the same action
|
|
5
|
+
// (close_immediately: true) or a resting dev1 limit order the run cancels at
|
|
6
|
+
// its end (mainnet-order-ledger.mjs).
|
|
5
7
|
|
|
6
|
-
import {
|
|
8
|
+
import { fixtureAccountNameOf } from '../wallet/fixture-accounts.mjs';
|
|
7
9
|
|
|
8
10
|
export const MAINNET_HOUSE_LIMITS = Object.freeze({
|
|
9
11
|
maxNotionalUsd: '15',
|
|
10
12
|
maxNotionalBaseUnits: 15_000_000n,
|
|
11
13
|
leverage: 1,
|
|
12
14
|
marginType: 'isolated',
|
|
15
|
+
limitOrderAccount: 'dev1',
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
const USDC_DECIMAL = /^(?:0|[1-9][0-9]{0,11})(?:\.([0-9]{1,6}))?$/u;
|
|
@@ -57,9 +60,13 @@ export function closeImmediatelyRequested(node) {
|
|
|
57
60
|
* @param {'market'|'limit'} [order.orderType]
|
|
58
61
|
* @param {boolean} order.closeImmediately
|
|
59
62
|
* @param {boolean} order.fixtureAccount - The selected account is a synced fixture account.
|
|
60
|
-
* @
|
|
63
|
+
* @param {string|null} [order.accountName] - Its fixture name.
|
|
64
|
+
* @param {boolean} [order.runCancelsLimit] - The caller records a limit order for the run's forced cancel.
|
|
65
|
+
* @returns {{ lane: 'testnet' } | { lane: 'mainnet-house-limits', notionalBaseUnits: string, autoCancel?: 'end-of-run' }}
|
|
61
66
|
*/
|
|
62
|
-
export function decidePerpsOrder({
|
|
67
|
+
export function decidePerpsOrder({
|
|
68
|
+
network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount, accountName = null, runCancelsLimit = false,
|
|
69
|
+
}) {
|
|
63
70
|
if (closeImmediately && orderType !== 'market') {
|
|
64
71
|
throw new Error('close_immediately needs a market order: a resting limit order is not a position the action can close.');
|
|
65
72
|
}
|
|
@@ -76,29 +83,42 @@ export function decidePerpsOrder({ network, amount, leverage, orderType = 'marke
|
|
|
76
83
|
if (notionalBaseUnits > MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits) {
|
|
77
84
|
broken.push(`notional ${formatUsd(notionalBaseUnits)} is above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}`);
|
|
78
85
|
}
|
|
79
|
-
if (orderType
|
|
80
|
-
|
|
86
|
+
if (orderType === 'limit') {
|
|
87
|
+
if (fixtureAccount && accountName !== MAINNET_HOUSE_LIMITS.limitOrderAccount) {
|
|
88
|
+
broken.push(`a limit order needs the ${MAINNET_HOUSE_LIMITS.limitOrderAccount} account, not ${accountName ?? 'an unnamed account'}`);
|
|
89
|
+
}
|
|
90
|
+
if (!runCancelsLimit) broken.push('this action cannot leave a limit order for the run to cancel at its end');
|
|
91
|
+
} else if (orderType !== 'market') {
|
|
92
|
+
broken.push(`order type is ${orderType}, not market or limit`);
|
|
93
|
+
} else if (!closeImmediately) {
|
|
94
|
+
broken.push('close_immediately is not true');
|
|
95
|
+
}
|
|
81
96
|
if (broken.length > 0) {
|
|
82
97
|
throw Object.assign(
|
|
83
98
|
new Error(
|
|
84
99
|
// The code leads the message: it is what survives a live adapter's process
|
|
85
100
|
// boundary, and the wording avoids the words bounded healing reads as wallet state.
|
|
86
101
|
`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: ${broken.join('; ')}. ` +
|
|
87
|
-
`Mainnet orders need a synced test account,
|
|
102
|
+
`Mainnet orders need a synced test account, 1x isolated, notional at most $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}, and either a market order with close_immediately: true ` +
|
|
103
|
+
`or a ${MAINNET_HOUSE_LIMITS.limitOrderAccount} limit order away from the market, which the run cancels at its end. ` +
|
|
88
104
|
'Next: use testnet, or stay within those limits.',
|
|
89
105
|
),
|
|
90
106
|
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
91
107
|
);
|
|
92
108
|
}
|
|
93
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
lane: 'mainnet-house-limits',
|
|
111
|
+
notionalBaseUnits: `${notionalBaseUnits}`,
|
|
112
|
+
...(orderType === 'limit' ? { autoCancel: 'end-of-run' } : {}),
|
|
113
|
+
};
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
// decidePerpsOrder with the fixture check resolved from the checkout's fixture.
|
|
97
117
|
export async function requirePerpsOrderAllowed({ projectRoot, selectedAddress, ...order }) {
|
|
98
|
-
const
|
|
99
|
-
? await
|
|
100
|
-
:
|
|
101
|
-
return decidePerpsOrder({ ...order, fixtureAccount });
|
|
118
|
+
const accountName = order.network === 'mainnet'
|
|
119
|
+
? await fixtureAccountNameOf(projectRoot, selectedAddress)
|
|
120
|
+
: null;
|
|
121
|
+
return decidePerpsOrder({ ...order, fixtureAccount: accountName !== null, accountName });
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
// close_immediately closes the market's whole position (positions are netted
|
|
@@ -143,26 +163,43 @@ export function marketOrderLot({ amount, leverage, price, szDecimals }) {
|
|
|
143
163
|
}
|
|
144
164
|
|
|
145
165
|
// Mainnet only: refuse before dispatch when the size the controller would submit
|
|
146
|
-
// could fill above the house notional limit. Testnet needs no bound.
|
|
147
|
-
|
|
166
|
+
// could fill above the house notional limit. Testnet needs no bound. A limit
|
|
167
|
+
// order (`limit: { price, isBuy }`) must rest away from the market, so it is
|
|
168
|
+
// bounded at its own price with no slippage: one that would fill now is refused.
|
|
169
|
+
export function requireHouseNotional({ network, symbol, amount, leverage, price, szDecimals, maxSlippageBps, limit = null }) {
|
|
148
170
|
if (network !== 'mainnet') return null;
|
|
149
171
|
// A missing value is unknown, never zero (Number(null) is 0).
|
|
150
172
|
const known = (value) => (value == null || value === '' ? Number.NaN : Number(value));
|
|
151
|
-
const inputs = {
|
|
173
|
+
const inputs = {
|
|
174
|
+
price: known(price),
|
|
175
|
+
szDecimals: known(szDecimals),
|
|
176
|
+
maxSlippageBps: limit ? 0 : known(maxSlippageBps),
|
|
177
|
+
};
|
|
178
|
+
const limitPrice = limit ? known(limit.price) : null;
|
|
152
179
|
if (!(inputs.price > 0) || !Number.isInteger(inputs.szDecimals) || inputs.szDecimals < 0
|
|
153
|
-
|| !Number.isFinite(inputs.maxSlippageBps) || inputs.maxSlippageBps < 0) {
|
|
180
|
+
|| !Number.isFinite(inputs.maxSlippageBps) || inputs.maxSlippageBps < 0 || (limit && !(limitPrice > 0))) {
|
|
154
181
|
throw Object.assign(
|
|
155
182
|
new Error(`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: cannot bound the ${symbol} order size (price, size decimals or slippage unknown).`),
|
|
156
183
|
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
157
184
|
);
|
|
158
185
|
}
|
|
159
|
-
|
|
186
|
+
if (limit && (limit.isBuy ? limitPrice >= inputs.price : limitPrice <= inputs.price)) {
|
|
187
|
+
throw Object.assign(
|
|
188
|
+
new Error(
|
|
189
|
+
`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: a ${limit.isBuy ? 'buy' : 'sell'} limit at $${limitPrice} ` +
|
|
190
|
+
`would fill now against the ${symbol} price $${inputs.price} and open a position. Next: set limit_price ${limit.isBuy ? 'below' : 'above'} the market.`,
|
|
191
|
+
),
|
|
192
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const boundPrice = limit ? limitPrice : inputs.price;
|
|
196
|
+
const worst = worstCaseNotionalUsd({ amount, leverage, ...inputs, price: boundPrice });
|
|
160
197
|
if (worst > Number(MAINNET_HOUSE_LIMITS.maxNotionalUsd)) {
|
|
161
198
|
throw Object.assign(
|
|
162
199
|
new Error(
|
|
163
200
|
`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: a ${symbol} order for $${Number(amount) * Number(leverage)} can fill at up to $${worst.toFixed(2)} ` +
|
|
164
|
-
`(size step ${10 **
|
|
165
|
-
|
|
201
|
+
`(size step ${1 / 10 ** inputs.szDecimals} at $${boundPrice}, max slippage ${inputs.maxSlippageBps} bps), above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}. ` +
|
|
202
|
+
`Next: order less${limit ? '' : ', or lower max_slippage_bps'}.`,
|
|
166
203
|
),
|
|
167
204
|
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
168
205
|
);
|
|
@@ -73,12 +73,18 @@ export async function readFixtureAccounts(projectRoot) {
|
|
|
73
73
|
// Whether an address is one of the fixture's accounts. A missing or unreadable
|
|
74
74
|
// fixture is "not a fixture account": the caller refuses, it never assumes.
|
|
75
75
|
export async function isFixtureAccount(projectRoot, address) {
|
|
76
|
+
return (await fixtureAccountNameOf(projectRoot, address)) !== null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The fixture name of an address (dev1, Trading, ...), or null when it is not
|
|
80
|
+
// one of the fixture's accounts or the fixture is missing or unreadable.
|
|
81
|
+
export async function fixtureAccountNameOf(projectRoot, address) {
|
|
76
82
|
const wanted = String(address ?? '').toLowerCase();
|
|
77
|
-
if (!/^0x[0-9a-f]{40}$/u.test(wanted) || !projectRoot) return
|
|
83
|
+
if (!/^0x[0-9a-f]{40}$/u.test(wanted) || !projectRoot) return null;
|
|
78
84
|
try {
|
|
79
85
|
const loaded = await readFixtureAccounts(projectRoot);
|
|
80
|
-
return
|
|
86
|
+
return loaded?.accounts.find((account) => account.address.toLowerCase() === wanted)?.name ?? null;
|
|
81
87
|
} catch {
|
|
82
|
-
return
|
|
88
|
+
return null;
|
|
83
89
|
}
|
|
84
90
|
}
|
|
@@ -4132,7 +4132,7 @@
|
|
|
4132
4132
|
"execution_capabilities": []
|
|
4133
4133
|
},
|
|
4134
4134
|
"metamask.perps.close_orders": {
|
|
4135
|
-
"description": "extension Cancel selected live Perps open orders. Use selector/mode params to cancel matching symbols or all orders. On mainnet,
|
|
4135
|
+
"description": "extension Cancel selected live Perps open orders. Use selector/mode params to cancel matching symbols or all orders. On mainnet, the fixture-policy lane cancels only orders this run placed; any other cancel needs the funded release lane. Extension ensure_* only assert existing state; converge an owned testnet account through an explicitly funded leaf action.",
|
|
4136
4136
|
"schema": {
|
|
4137
4137
|
"type": "object",
|
|
4138
4138
|
"properties": {
|
|
@@ -4253,7 +4253,7 @@
|
|
|
4253
4253
|
},
|
|
4254
4254
|
"order_id": {
|
|
4255
4255
|
"type": "string",
|
|
4256
|
-
"description": "Exact product order ID required for receipt-bound background cancellation."
|
|
4256
|
+
"description": "Exact product order ID required for receipt-bound background cancellation. On mainnet without funding files, only an order this run placed."
|
|
4257
4257
|
},
|
|
4258
4258
|
"offset_pct": {
|
|
4259
4259
|
"type": "number"
|
|
@@ -4410,7 +4410,7 @@
|
|
|
4410
4410
|
},
|
|
4411
4411
|
"close_immediately": {
|
|
4412
4412
|
"type": "boolean",
|
|
4413
|
-
"description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat).
|
|
4413
|
+
"description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat). A mainnet market order needs it, with leverage 1 and notional at most $15 from a fixture account; a mainnet limit order takes none (the run cancels it at its end)."
|
|
4414
4414
|
},
|
|
4415
4415
|
"provider": {
|
|
4416
4416
|
"type": "string",
|
|
@@ -4422,7 +4422,7 @@
|
|
|
4422
4422
|
"mainnet",
|
|
4423
4423
|
"testnet"
|
|
4424
4424
|
],
|
|
4425
|
-
"description": "Exact Perps network required before background dispatch; the action never toggles it."
|
|
4425
|
+
"description": "Exact Perps network required before background dispatch; the action never toggles it (metamask.perps.set_network does). A mainnet order is a market order with close_immediately, or a dev1 limit order away from the market at 1x and at most $15 notional at its limit price, which the run cancels at its end (success, failure or abort) and records in perps-mainnet-orders.json."
|
|
4426
4426
|
},
|
|
4427
4427
|
"order_type": {
|
|
4428
4428
|
"type": "string",
|
|
@@ -5161,6 +5161,36 @@
|
|
|
5161
5161
|
"app-mutation"
|
|
5162
5162
|
]
|
|
5163
5163
|
},
|
|
5164
|
+
"metamask.perps.set_network": {
|
|
5165
|
+
"description": "Switch the Extension's Perps network through the background perpsToggleTestnet request and wait until the app reports it. The fixture loads testnet; network=mainnet is the explicit opt-in and lasts one run: the run's teardown switches back to testnet (success, failure or abort).",
|
|
5166
|
+
"schema": {
|
|
5167
|
+
"type": "object",
|
|
5168
|
+
"properties": {
|
|
5169
|
+
"network": {
|
|
5170
|
+
"type": "string",
|
|
5171
|
+
"enum": ["testnet", "mainnet"],
|
|
5172
|
+
"default": "testnet",
|
|
5173
|
+
"description": "Perps network to select; unchanged when already selected."
|
|
5174
|
+
},
|
|
5175
|
+
"timeout_ms": {
|
|
5176
|
+
"type": "number",
|
|
5177
|
+
"description": "How long to wait for the app to report the new network."
|
|
5178
|
+
}
|
|
5179
|
+
},
|
|
5180
|
+
"additionalProperties": false
|
|
5181
|
+
},
|
|
5182
|
+
"examples": [
|
|
5183
|
+
{
|
|
5184
|
+
"action": "metamask.perps.set_network",
|
|
5185
|
+
"network": "mainnet",
|
|
5186
|
+
"intent": "Switch Perps to mainnet for this run",
|
|
5187
|
+
"next": "done"
|
|
5188
|
+
}
|
|
5189
|
+
],
|
|
5190
|
+
"execution_capabilities": [
|
|
5191
|
+
"app-mutation"
|
|
5192
|
+
]
|
|
5193
|
+
},
|
|
5164
5194
|
"metamask.deeplink.open": {
|
|
5165
5195
|
"description": "Open an HTTPS deeplink through real browser ingress so the Extension owns parsing, verification, and destination navigation.",
|
|
5166
5196
|
"execution_capabilities": [
|