@deeeed/metamask-harness 0.86.2 → 0.88.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 +49 -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/action-inputs.js +20 -0
- package/dist/adapters/mobile/actions.js +18 -1
- package/dist/adapters/mobile/android-screenrecord.js +274 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +54 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +58 -15
- package/dist/adapters/mobile/ui.js +96 -2
- package/dist/adapters/perps-mainnet-cleanup.js +122 -0
- package/dist/adapters/perps-order-inputs.js +43 -28
- package/dist/adapters.js +4 -1
- package/dist/commands/doctor.js +33 -14
- package/dist/commands/status-probe.js +2 -1
- 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 +72 -0
- package/dist/recipe-security.js +1 -0
- package/docs/CONTRIBUTING.md +7 -0
- package/docs/RECIPES.md +96 -9
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
- package/docs/cli-contract.md +1 -1
- package/library/actions/core/perps/read_account.mjs +4 -2
- package/library/actions/core/perps/read_orders.mjs +4 -2
- package/library/actions/core/perps/read_positions.mjs +4 -2
- package/library/actions/extension/analytics/consent.mjs +7 -2
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
- package/library/actions/extension/perps/perps.mjs +298 -39
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- package/library/actions/extension/perps/set_network.mjs +6 -0
- package/library/actions/extension/platform/cdp.mjs +6 -2
- package/library/actions/extension/ui/navigate.mjs +8 -0
- package/library/actions/mobile/perps/perps.mjs +264 -102
- package/library/actions/mobile/perps/place_order.mjs +7 -4
- package/library/actions/mobile/platform/bridge.mjs +171 -10
- package/library/actions/mobile/wallet/home.mjs +7 -2
- package/library/actions/mobile/wallet/select_account.mjs +4 -0
- package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
- 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/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
- package/library/manifests/extension.action-manifest.json +36 -5
- package/library/manifests/mobile.action-manifest.json +48 -10
- package/package.json +14 -9
- package/scripts/validate-release-report.mjs +14 -2
- package/site/assets/help-recipes.json +2 -1
package/docs/RECIPES.md
CHANGED
|
@@ -261,12 +261,34 @@ Perps orders follow one rule on Mobile, Extension and Core
|
|
|
261
261
|
(`library/actions/shared/perps/order-policy.mjs`), checked before dispatch:
|
|
262
262
|
|
|
263
263
|
- Testnet: allowed with no funding proof.
|
|
264
|
-
- Mainnet: only from a synced fixture account (`wallet-fixture.json`),
|
|
265
|
-
isolated
|
|
266
|
-
|
|
267
|
-
submit: (amount + one size step × price) × (1 + max
|
|
268
|
-
|
|
269
|
-
|
|
264
|
+
- Mainnet: only from a synced fixture account (`wallet-fixture.json`), 1x
|
|
265
|
+
isolated, and the node must declare `network: mainnet`. Either a market order
|
|
266
|
+
with `close_immediately: true`, where the $15 limit bounds the size the
|
|
267
|
+
controller would submit: (amount + one size step × price) × (1 + max
|
|
268
|
+
slippage); or, on Mobile and Extension `place_order`, a `dev1` Hyperliquid
|
|
269
|
+
limit order away from the live market price, sent post-only and bounded at its limit
|
|
270
|
+
price: amount + one size step × limit price. Mainnet orders take no
|
|
271
|
+
`current_price`. Anything else is refused with `PERPS_MAINNET_ORDER_REFUSED`,
|
|
272
|
+
naming each broken limit, and no order is sent: more than 1x or $15, a limit
|
|
273
|
+
that would fill now, another account, Core, and `ensure_orders` or
|
|
274
|
+
`ensure_positions` opening state.
|
|
275
|
+
- A mainnet limit order lasts one run. It is tracked before dispatch in
|
|
276
|
+
`temp/recipe/runtime/perps-mainnet-orders.json`, and the run cancels whatever
|
|
277
|
+
is still open when it ends (success, failure, or abort by signal; after a
|
|
278
|
+
kill, the next run on the checkout). The order counts as gone only when the
|
|
279
|
+
Hyperliquid API, read for the placing address, no longer lists it. Each
|
|
280
|
+
order's ID, cancel result and both reads (app and venue) land in
|
|
281
|
+
`<artifacts>/perps-mainnet-orders.json`. An order
|
|
282
|
+
that cannot be proven cancelled, or that filled (the market's position moved),
|
|
283
|
+
fails the run with `PERPS_MAINNET_ORDER_LEFT_OPEN`; a fill is reported, never
|
|
284
|
+
closed for you. On mainnet, `close_orders` cancels only orders the run placed,
|
|
285
|
+
and leaving mainnet while the run holds one is refused
|
|
286
|
+
(`PERPS_MAINNET_ORDER_OPEN`).
|
|
287
|
+
- Switching to mainnet: Mobile `metamask.perps.start_state network=mainnet`;
|
|
288
|
+
Extension `metamask.perps.set_network network=mainnet`, which the run switches
|
|
289
|
+
back to testnet at its end (the fixture loads testnet). Mobile stays on
|
|
290
|
+
mainnet after the run: switch back with `start_state network=testnet` once
|
|
291
|
+
the run's orders are settled.
|
|
270
292
|
- `close_immediately: true` needs a market order from a flat market: no
|
|
271
293
|
position and no resting order in it. Positions are netted per market, so the
|
|
272
294
|
close takes the market's whole position; don't share a fixture account's
|
|
@@ -279,6 +301,18 @@ Perps orders follow one rule on Mobile, Extension and Core
|
|
|
279
301
|
sent or closed.
|
|
280
302
|
- Core still needs `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any mainnet write.
|
|
281
303
|
|
|
304
|
+
A Hyperliquid `429 Too Many Requests` fails as `RATE_LIMITED`, naming the call,
|
|
305
|
+
the attempts and the backoff (`library/actions/shared/perps/rate-limit.mjs`).
|
|
306
|
+
Reads retry up to 3 times with exponential backoff and jitter inside the action
|
|
307
|
+
deadline; then wait a minute and rerun. Extension background placement retries
|
|
308
|
+
only when the dispatch itself got the 429 and Hyperliquid's info API then shows
|
|
309
|
+
no open order, no fill and (market orders) no position on the order's side in
|
|
310
|
+
that market since the first dispatch. The receipt records every dispatch 429
|
|
311
|
+
under `rateLimitedDispatches`, with that check or the reason it did not retry;
|
|
312
|
+
without a retry it stays `outcome-unknown`: inspect the market before any rerun.
|
|
313
|
+
A 429 after dispatch is never redispatched, and Mobile placements are not
|
|
314
|
+
retried.
|
|
315
|
+
|
|
282
316
|
On the Extension, a run without funding files gets that rule through the
|
|
283
317
|
fixture-policy lane: the runner binds the run and a run-owned execution ledger
|
|
284
318
|
(`<artifacts>/mutation-ledger`), and each mutation completes the context from
|
|
@@ -330,9 +364,11 @@ rather than refuse. Extension `ensure_*` and `teardown_state` only assert, so
|
|
|
330
364
|
converge there through an explicitly funded leaf action. Never broaden cleanup
|
|
331
365
|
onto an account or market the task does not own; obtain an owned one instead
|
|
332
366
|
of blocking. On mainnet, orders follow the Perps order rule above, Core needs
|
|
333
|
-
`CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any write, Mobile
|
|
334
|
-
whatever network the app is on,
|
|
335
|
-
orders and
|
|
367
|
+
`CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any write, Mobile `close_positions`
|
|
368
|
+
acts on whatever network the app is on, `close_orders` there cancels only the
|
|
369
|
+
run's own orders, and Extension refuses mainnet writes other than orders,
|
|
370
|
+
`close_visible_position` and cancelling the run's own orders in the
|
|
371
|
+
fixture-policy lane.
|
|
336
372
|
|
|
337
373
|
### Mobile advanced-order setup
|
|
338
374
|
|
|
@@ -417,6 +453,57 @@ For visual PR proof, prefer a short simulator recording or GIF over still
|
|
|
417
453
|
screenshots when the claim is a UI flow (timing / transitions). Use screenshots
|
|
418
454
|
as supporting stills.
|
|
419
455
|
|
|
456
|
+
`--record-video=full-run` on a physical Android device records on the device
|
|
457
|
+
with `adb shell screenrecord`, so no other host screen-capture client can stop
|
|
458
|
+
it. screenrecord ends each file at 180 s; the harness starts the next one at
|
|
459
|
+
once and joins them with ffmpeg (required, or set `FFMPEG_PATH`). Each restart
|
|
460
|
+
leaves a gap of about half a second, which the video holds on the last frame
|
|
461
|
+
so video time keeps to wall-clock time. `videos/recipe-run.mp4.capture.json`
|
|
462
|
+
lists the files and their offsets. Screens the app marks secure (FLAG_SECURE)
|
|
463
|
+
record black. An emulator (`emulator-*`) still records its scrcpy window
|
|
464
|
+
through capture-helper, falling back to one screenrecord file when the mirror
|
|
465
|
+
is unavailable. Set `METAMASK_RECIPE_MOBILE_ANDROID_RECORDER` to
|
|
466
|
+
`screenrecord` or `mirror` to force either.
|
|
467
|
+
|
|
468
|
+
A recording that fails fails the run: the `record.video` trace entry starts
|
|
469
|
+
with `RECORDING_FAILED` or `RECORDER_INTERRUPTED`, names the cause, and says the
|
|
470
|
+
run has no video. When capture stopped mid-run but a finalized partial file was
|
|
471
|
+
kept (capture-helper 0.3.1's `stream_interrupted`, or the screenrecord files
|
|
472
|
+
finished before a failure), the video is published, its manifest entry's
|
|
473
|
+
`interruption` gives the frames, the video time up to the last captured frame
|
|
474
|
+
(`mediaTimeMs`) and the cause, and the run fails as `CAPTURE_INTERRUPTED`, a
|
|
475
|
+
capture (environment) failure, not a product one. `mm-harness doctor` shows the
|
|
476
|
+
recorder for the selected device and is not ready while it cannot record.
|
|
477
|
+
|
|
478
|
+
### Mobile setup and command budgets
|
|
479
|
+
|
|
480
|
+
`metamask.perps.start_state` and `teardown_state` have three budgets:
|
|
481
|
+
|
|
482
|
+
| Budget | Node field | Emulator / simulator | Physical Android | Env override |
|
|
483
|
+
|---|---|---|---|---|
|
|
484
|
+
| Whole setup | `timeout_ms` | 30 s | 60 s | `MM_HARNESS_MOBILE_SETUP_TIMEOUT_MS` |
|
|
485
|
+
| Each navigation phase | `navigation_timeout_ms` | 15 s | 30 s | `MM_HARNESS_MOBILE_NAVIGATION_TIMEOUT_MS` |
|
|
486
|
+
| Each bridge command | `cdp_timeout_ms` | 15 s | 15 s | none |
|
|
487
|
+
|
|
488
|
+
A device counts as physical when the run has an explicit adb serial (`--device <serial>`
|
|
489
|
+
or `ADB_SERIAL`) that is not `emulator-*`. Node fields win over the env overrides.
|
|
490
|
+
Each phase (unlock, account, navigation, provider, network, cleanup) runs on the
|
|
491
|
+
rest of the setup budget, or on its slice for navigation, and each bridge command
|
|
492
|
+
inside it is capped by that phase deadline. `navigate` and Perps market/home
|
|
493
|
+
navigation use the same navigation default outside setups.
|
|
494
|
+
|
|
495
|
+
Errors say which budget ran out:
|
|
496
|
+
|
|
497
|
+
- `DOMAIN_SETUP_DEADLINE`: a setup phase ran out. The message names the action and
|
|
498
|
+
phase, the time it ran against its limit, and the setup's elapsed time and
|
|
499
|
+
limit. Raise `timeout_ms` or `navigation_timeout_ms`.
|
|
500
|
+
- `ACTION_DEADLINE`: the calling action's deadline refused or stopped a bridge
|
|
501
|
+
command. Route and mode waits treat it as "no answer yet" and end with their
|
|
502
|
+
own timeout message.
|
|
503
|
+
- `CDP_TIMEOUT` with `CDP_COMMAND_TIMEOUT` in the message: one bridge command hit
|
|
504
|
+
its own limit while the action still had time. That points at the bridge or app,
|
|
505
|
+
not the budget.
|
|
506
|
+
|
|
420
507
|
### Perps loading lifecycle validation
|
|
421
508
|
|
|
422
509
|
The Mobile loading recipe shares one graph across Android and iOS. It requires
|
|
@@ -63,7 +63,11 @@ outside the declared root, incomplete run directories, and any non-empty
|
|
|
63
63
|
|
|
64
64
|
Supported verdicts are `PASS`, `FAIL`, `BLOCKED_FUNDS`, `BLOCKED_COST`, and
|
|
65
65
|
`BLOCKED_ENVIRONMENT`. Product failures must remain failures; infrastructure,
|
|
66
|
-
funding, and cost limits must not be reported as passes.
|
|
66
|
+
funding, and cost limits must not be reported as passes. If every run cited by a
|
|
67
|
+
case failed only on `CAPTURE_INTERRUPTED`, the gate requires `BLOCKED_ENVIRONMENT`
|
|
68
|
+
with "evidence incomplete (capture interrupted)" in its summary and the partial
|
|
69
|
+
video in its run directory. Retain interrupted attempts alongside the retry's
|
|
70
|
+
evidence. A passing retry can be `PASS`; a product-failing retry remains `FAIL`.
|
|
67
71
|
|
|
68
72
|
## Capability matrix
|
|
69
73
|
|
package/docs/cli-contract.md
CHANGED
|
@@ -146,7 +146,7 @@ The goldens need the harness to be a git checkout: `execution-provenance.json` `
|
|
|
146
146
|
2. UUIDs become `<UUID>`. Git object ids (40 hex, or a short hex value under a `*head`/`*gitRef`/`*commit` key) become `<GIT_REF>`. ISO-8601 timestamps become `<TIMESTAMP>`, and compact stamps in names `<STAMP>`. Path-derived slot ids `local-<adapter>-<8 hex>` become `local-<adapter>-<HASH>`.
|
|
147
147
|
3. The package version becomes `<HARNESS_VERSION>`. Any other `x.y.z` under a key ending in `version` becomes `<VERSION>`.
|
|
148
148
|
4. Numbers under volatile keys: `*pid` becomes `<PID>`, `inode` `<INODE>`, `*port` `<PORT>`, `*At`/`*Time`/`*timestamp`/`mtime*` `<TIMESTAMP>`, and `*Ms`/`duration*`/`elapsed*` `<DURATION>`. Values inside `schema` and `examples` subtrees (static manifest content) are never rewritten.
|
|
149
|
-
5. `invocationDigest` becomes `<DIGEST>`: it hashes `recipe-invocation.json`, which carries sandbox paths and timestamps and is frozen itself in normalised form. `runner.sourceFingerprint` becomes `<FINGERPRINT>`: it hashes the harness source tree, so any harness edit changes it. A `status` holding `git status --porcelain` output (provenance `runner.status` and `libraries[].status`, empty when clean) becomes `<GIT_STATUS>`. A loaded adapter plugin's code digest in doctor's `adapter-plugin:<id>` message becomes `sha256:<PLUGIN_DIGEST>`: it hashes the recipe library's plugin files, so any library edit changes it.
|
|
149
|
+
5. `invocationDigest` becomes `<DIGEST>`: it hashes `recipe-invocation.json`, which carries sandbox paths and timestamps and is frozen itself in normalised form. `runner.sourceFingerprint` becomes `<FINGERPRINT>`: it hashes the harness source tree, so any harness edit changes it. A `status` holding `git status --porcelain` output (provenance `runner.status` and `libraries[].status`, empty when clean) becomes `<GIT_STATUS>`. A loaded adapter plugin's code digest in doctor's `adapter-plugin:<id>` message becomes `sha256:<PLUGIN_DIGEST>`: it hashes the recipe library's plugin files, so any library edit changes it. Within `executionPlan`, SHA-256 `digest` and `executionContextDigest` values become `sha256:<DIGEST>` because plans bind sandbox paths, environment and loaded source bytes. `schema` and `examples` remain exempt. Other content-only digests (recipe and dependency digests) stay frozen.
|
|
150
150
|
6. Durations in text (`0.3s`, `123ms`) become `<DURATION>`.
|
|
151
151
|
|
|
152
152
|
In tree listings, files whose names differ only by a volatile token collapse into one row: a count and the union of their key shapes, or the sorted list of their values in a value tree.
|
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
redactAccount,
|
|
7
7
|
runAdapter,
|
|
8
8
|
} from './_controller.mjs';
|
|
9
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
10
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
9
11
|
|
|
10
12
|
export async function readAccount(input) {
|
|
11
13
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
12
|
-
const account = await controller.getAccountState({
|
|
14
|
+
const account = await withRateLimitRetry('PerpsController.getAccountState', () => controller.getAccountState({
|
|
13
15
|
standalone: true,
|
|
14
16
|
userAddress: accountAddress,
|
|
15
|
-
});
|
|
17
|
+
}), { deadline: liveAdapterDeadline(input.node) });
|
|
16
18
|
return {
|
|
17
19
|
action: input.action,
|
|
18
20
|
source: 'perps-controller-standalone',
|
|
@@ -7,13 +7,15 @@ import {
|
|
|
7
7
|
runAdapter,
|
|
8
8
|
selectedItems,
|
|
9
9
|
} from './_controller.mjs';
|
|
10
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
11
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
10
12
|
|
|
11
13
|
export async function readOrders(input) {
|
|
12
14
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
13
|
-
const orders = await controller.getOpenOrders({
|
|
15
|
+
const orders = await withRateLimitRetry('PerpsController.getOpenOrders', () => controller.getOpenOrders({
|
|
14
16
|
standalone: true,
|
|
15
17
|
userAddress: accountAddress,
|
|
16
|
-
});
|
|
18
|
+
}), { deadline: liveAdapterDeadline(input.node) });
|
|
17
19
|
const matching = selectedItems(input, orders);
|
|
18
20
|
return {
|
|
19
21
|
action: input.action,
|
|
@@ -7,13 +7,15 @@ import {
|
|
|
7
7
|
runAdapter,
|
|
8
8
|
selectedItems,
|
|
9
9
|
} from './_controller.mjs';
|
|
10
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
11
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
10
12
|
|
|
11
13
|
export async function readPositions(input) {
|
|
12
14
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
13
|
-
const positions = await controller.getPositions({
|
|
15
|
+
const positions = await withRateLimitRetry('PerpsController.getPositions', () => controller.getPositions({
|
|
14
16
|
standalone: true,
|
|
15
17
|
userAddress: accountAddress,
|
|
16
|
-
});
|
|
18
|
+
}), { deadline: liveAdapterDeadline(input.node) });
|
|
17
19
|
const matching = selectedItems(input, positions, 'position');
|
|
18
20
|
return {
|
|
19
21
|
action: input.action,
|
|
@@ -85,18 +85,23 @@ export function consentControllerExpression({
|
|
|
85
85
|
throw new Error('Extension consent state is unavailable.');
|
|
86
86
|
}
|
|
87
87
|
const metamask = root?.metamask ?? root ?? {};
|
|
88
|
+
// Extension 13.51+ keeps marketing consent in AnalyticsController.optedInToMarketing
|
|
89
|
+
// (migration 228); older builds expose MetaMetricsController.dataCollectionForMarketing.
|
|
90
|
+
const dataCollectionForMarketing = typeof metamask.dataCollectionForMarketing === 'boolean'
|
|
91
|
+
? metamask.dataCollectionForMarketing
|
|
92
|
+
: metamask.optedInToMarketing;
|
|
88
93
|
// Debug UI state can lag an acknowledged setter until the next UI sync.
|
|
89
94
|
// Background/snapshot reads have no such subscription to wait for.
|
|
90
95
|
if ((capturedChrome || !storeState) && (
|
|
91
96
|
typeof metamask.optedIn !== 'boolean' ||
|
|
92
|
-
typeof
|
|
97
|
+
typeof dataCollectionForMarketing !== 'boolean'
|
|
93
98
|
)) {
|
|
94
99
|
throw new Error('Extension consent state did not contain boolean consent fields.');
|
|
95
100
|
}
|
|
96
101
|
return {
|
|
97
102
|
state: {
|
|
98
103
|
optedIn: metamask.optedIn,
|
|
99
|
-
dataCollectionForMarketing
|
|
104
|
+
dataCollectionForMarketing,
|
|
100
105
|
analyticsId: metamask.analyticsId ? 'set' : null
|
|
101
106
|
},
|
|
102
107
|
source: capturedChrome
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Fetch production Hyperliquid data and compare it with the opened market's displayed values.
|
|
2
2
|
// Also check the funding countdown and nonblank chart canvases.
|
|
3
3
|
import { dataTestId, marketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
4
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
5
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
4
6
|
|
|
5
7
|
function numeric(value) {
|
|
6
8
|
const match = String(value ?? '').replace(/[$,]/gu, '').match(/-?[0-9]+(?:\.[0-9]+)?/u);
|
|
@@ -63,14 +65,16 @@ runAdapter(async (input) => {
|
|
|
63
65
|
if (endpoint !== 'https://api.hyperliquid.xyz/info') {
|
|
64
66
|
throw new Error('metamask.perps.compare_provider_market endpoint must be the Hyperliquid production info API.');
|
|
65
67
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const payload = await withRateLimitRetry('Hyperliquid metaAndAssetCtxs', async () => {
|
|
69
|
+
const response = await fetch(endpoint, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
|
|
73
|
+
signal: AbortSignal.timeout(Number(input.node?.timeout_ms ?? 15_000)),
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) throw new Error(`Hyperliquid market request failed with HTTP ${response.status}.`);
|
|
76
|
+
return response.json();
|
|
77
|
+
}, { deadline: liveAdapterDeadline(input.node) });
|
|
74
78
|
const universe = payload?.[0]?.universe;
|
|
75
79
|
const contexts = payload?.[1];
|
|
76
80
|
const index = Array.isArray(universe) ? universe.findIndex((entry) => entry?.name === market) : -1;
|
|
@@ -19,6 +19,14 @@ import path from 'node:path';
|
|
|
19
19
|
|
|
20
20
|
import { isFixtureAccount } from '../../shared/wallet/fixture-accounts.mjs';
|
|
21
21
|
import { MAINNET_HOUSE_LIMITS, orderNetwork, requirePerpsOrderAllowed } from '../../shared/perps/order-policy.mjs';
|
|
22
|
+
import {
|
|
23
|
+
RATE_LIMIT_RETRIES,
|
|
24
|
+
RATE_LIMITED,
|
|
25
|
+
backoffSleep,
|
|
26
|
+
describeRateLimit,
|
|
27
|
+
isRateLimited,
|
|
28
|
+
rateLimitBackoffMs,
|
|
29
|
+
} from '../../shared/perps/rate-limit.mjs';
|
|
22
30
|
import { readWalletStateExpression } from '../wallet/state.mjs';
|
|
23
31
|
import { readPerpsRuntimeStateExpression } from './state.mjs';
|
|
24
32
|
|
|
@@ -43,6 +51,14 @@ function recoveryError(receipt, detail) {
|
|
|
43
51
|
);
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
// The outcome stays unknown, so the Next is still the receipt's, never a plain rerun.
|
|
55
|
+
function rateLimitedRecoveryError(receipt, rateLimit, reason) {
|
|
56
|
+
return Object.assign(
|
|
57
|
+
new Error(`${RATE_LIMITED}: ${recoveryError(receipt, `${describeRateLimit(rateLimit)}; ${reason}`).message}`),
|
|
58
|
+
{ code: RATE_LIMITED },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
class ExternalMutationNotDispatchedError extends Error {}
|
|
47
63
|
|
|
48
64
|
export function externalMutationNotDispatched(message) {
|
|
@@ -212,8 +228,9 @@ export function inFixturePolicyLane(input) {
|
|
|
212
228
|
* non-order mutation (allowed on testnet; on mainnet only a close from a fixture account).
|
|
213
229
|
* @returns {{ input, network, policy }} input with the completed context.
|
|
214
230
|
*/
|
|
215
|
-
export async function withFixturePolicyContext(input, page, { operations, order = null, mainnetAllowed = false }) {
|
|
216
|
-
// mainnetAllowed: a non-order mutation (a close
|
|
231
|
+
export async function withFixturePolicyContext(input, page, { operations, order = null, mainnetAllowed = false, mainnetRefusal = null }) {
|
|
232
|
+
// mainnetAllowed: a non-order mutation (a close, or a cancel of an order this
|
|
233
|
+
// run placed) a fixture account may run on mainnet; mainnetRefusal says why not.
|
|
217
234
|
if (!inFixturePolicyLane(input)) return { input, network: null, policy: null };
|
|
218
235
|
const state = await page.evaluate(readPerpsRuntimeStateExpression());
|
|
219
236
|
if (!state?.available || !state?.networkKnown) {
|
|
@@ -230,7 +247,7 @@ export async function withFixturePolicyContext(input, page, { operations, order
|
|
|
230
247
|
if (!order && network === 'mainnet'
|
|
231
248
|
&& (!mainnetAllowed || !(await isFixtureAccount(projectRoot, address)))) {
|
|
232
249
|
throw Object.assign(
|
|
233
|
-
new Error(`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet needs the funding-file release lane${mainnetAllowed ? ' or a synced test account' : ''}; refused before dispatch.`),
|
|
250
|
+
new Error(`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet ${mainnetRefusal && !mainnetAllowed ? `${mainnetRefusal} without funding files` : `needs the funding-file release lane${mainnetAllowed ? ' or a synced test account' : ''}`}; refused before dispatch.`),
|
|
234
251
|
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
235
252
|
);
|
|
236
253
|
}
|
|
@@ -848,9 +865,9 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
848
865
|
|| !validFilledOrder(filledSize, averagePrice)) {
|
|
849
866
|
throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
|
|
850
867
|
}
|
|
851
|
-
return
|
|
868
|
+
return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
|
|
852
869
|
source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
|
|
853
|
-
});
|
|
870
|
+
} }, 'submission acceptance');
|
|
854
871
|
}
|
|
855
872
|
const closing = receipt.record.identity.operation === 'perps-close-position';
|
|
856
873
|
const market = closing || receipt.record.identity.operation === 'perps-market-order';
|
|
@@ -871,19 +888,25 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
871
888
|
if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
|
|
872
889
|
throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
|
|
873
890
|
}
|
|
874
|
-
return
|
|
891
|
+
return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
|
|
875
892
|
venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
|
|
876
893
|
...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
|
|
877
|
-
});
|
|
894
|
+
} }, 'submission acceptance');
|
|
878
895
|
}
|
|
879
896
|
|
|
880
|
-
|
|
897
|
+
// Adds fields to a still outcome-unknown receipt: the accepted order, or a 429
|
|
898
|
+
// and the venue read that followed it.
|
|
899
|
+
async function amendOutcomeUnknownReceipt(input, receipt, fields, label) {
|
|
881
900
|
const destination = await receiptDestination(input);
|
|
882
901
|
const current = await readPublished(destination);
|
|
883
902
|
if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
|
|
884
|
-
throw recoveryError(receipt,
|
|
903
|
+
throw recoveryError(receipt, `the receipt changed before ${label} was recorded`);
|
|
904
|
+
}
|
|
905
|
+
const record = { ...current.record, ...fields };
|
|
906
|
+
const bytes = canonical(record);
|
|
907
|
+
if (bytes.length > 12_288 || /secret|password|srp|mnemonic|private.?key/iu.test(bytes)) {
|
|
908
|
+
throw recoveryError(receipt, `the ${label} receipt is unsafe`);
|
|
885
909
|
}
|
|
886
|
-
const record = { ...current.record, acceptance };
|
|
887
910
|
const staged = await stagedRecord(destination.directory, record);
|
|
888
911
|
try {
|
|
889
912
|
await rename(staged.stagedPath, destination.absolute);
|
|
@@ -893,12 +916,21 @@ async function writeSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
893
916
|
}
|
|
894
917
|
const published = await readPublished(destination);
|
|
895
918
|
if (!published || canonical(published.record) !== canonical(record)) {
|
|
896
|
-
throw recoveryError(receipt,
|
|
919
|
+
throw recoveryError(receipt, `${label} could not be reread`);
|
|
897
920
|
}
|
|
898
921
|
return { ...published, resumed: receipt.resumed };
|
|
899
922
|
}
|
|
900
923
|
|
|
901
|
-
|
|
924
|
+
/**
|
|
925
|
+
* Publish the receipt, dispatch once, observe the postcondition and complete the receipt.
|
|
926
|
+
* @param rateLimit - Optional retry of a dispatch the venue refused with a 429:
|
|
927
|
+
* { call, deadline, verifyBudgetMs, verifyNotPlaced({ sinceMs }) -> { proven, reason?, facts? }, sleep?, random? }.
|
|
928
|
+
* A retry runs only when the dispatch itself threw the 429 (nothing accepted), the backoff and
|
|
929
|
+
* the venue read fit before the deadline, and verifyNotPlaced then proves from the venue that
|
|
930
|
+
* nothing was placed since the first dispatch. Every 429 is recorded in the receipt with its
|
|
931
|
+
* verification or the reason it was not retried.
|
|
932
|
+
*/
|
|
933
|
+
export async function runGuardedExternalMutation({ input, identity, dispatch, observe, publicationFacts, receiptIo, rateLimit = null }) {
|
|
902
934
|
let receipt = await beginExternalMutation(input, identity, receiptIo, publicationFacts);
|
|
903
935
|
if (receipt.record?.state === 'observed-policy-violation') {
|
|
904
936
|
throw new Error('External mutation receipt records an observed policy violation and is cleanup-only.');
|
|
@@ -907,27 +939,93 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
|
|
|
907
939
|
throw new Error('External mutation receipt state is unsafe.');
|
|
908
940
|
}
|
|
909
941
|
if (!receipt.resumed) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
)
|
|
942
|
+
const call = rateLimit?.call ?? `${identity.action} dispatch`;
|
|
943
|
+
const sinceMs = Date.now();
|
|
944
|
+
let waitedMs = 0;
|
|
945
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
946
|
+
let returned = false;
|
|
947
|
+
try {
|
|
948
|
+
// A retry must reach the venue before the deadline, its pre-dispatch checks included.
|
|
949
|
+
const acceptance = await dispatch(attempt > 1 ? { deadline: rateLimit.deadline } : {});
|
|
950
|
+
returned = true;
|
|
951
|
+
if (acceptance?.venueOrderId) receipt = await recordSubmissionAcceptance(input, receipt, acceptance);
|
|
952
|
+
break;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
if (error instanceof ExternalMutationNotDispatchedError) {
|
|
955
|
+
await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
|
|
956
|
+
// A retry that was not sent still ends the action on the 429 that called for it.
|
|
957
|
+
const retried = attempt > 1;
|
|
958
|
+
throw Object.assign(
|
|
959
|
+
new Error(
|
|
960
|
+
`${retried ? `${RATE_LIMITED}: ` : ''}External mutation was safely not dispatched (` +
|
|
961
|
+
`${retried ? `${describeRateLimit({ call, attempts: attempt - 1, waitedMs })}; the retry was not sent: ` : ''}` +
|
|
962
|
+
`${error.message}). ` +
|
|
963
|
+
'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
|
|
964
|
+
),
|
|
965
|
+
{ knownNotDispatched: true, ...(retried ? { code: RATE_LIMITED } : {}) },
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
969
|
+
if (returned || !isRateLimited(error)) {
|
|
970
|
+
throw recoveryError(receipt, `pointer dispatch may have occurred (${message})`);
|
|
971
|
+
}
|
|
972
|
+
const deadline = rateLimit?.deadline;
|
|
973
|
+
const passesDeadline = (ms) => Number.isFinite(deadline) && Date.now() + ms >= deadline;
|
|
974
|
+
const backoffMs = attempt > RATE_LIMIT_RETRIES ? 0 : rateLimitBackoffMs(attempt, rateLimit?.random);
|
|
975
|
+
let refusal = !rateLimit?.verifyNotPlaced ? 'this mutation has no venue read that can prove nothing was placed'
|
|
976
|
+
: receipt.record.acceptance ? 'the receipt already records an accepted order'
|
|
977
|
+
: attempt > RATE_LIMIT_RETRIES ? 'its retries are spent'
|
|
978
|
+
: passesDeadline(backoffMs + (rateLimit.verifyBudgetMs ?? 0))
|
|
979
|
+
? `the next ${backoffMs} ms backoff and the venue read would pass the action deadline` : null;
|
|
980
|
+
let verification = null;
|
|
981
|
+
if (!refusal) {
|
|
982
|
+
await (rateLimit.sleep ?? backoffSleep)(backoffMs);
|
|
983
|
+
waitedMs += backoffMs;
|
|
984
|
+
try {
|
|
985
|
+
verification = await rateLimit.verifyNotPlaced({ sinceMs });
|
|
986
|
+
} catch (verifyError) {
|
|
987
|
+
verification = {
|
|
988
|
+
proven: false,
|
|
989
|
+
reason: `the venue read failed (${verifyError instanceof Error ? verifyError.message : String(verifyError)})`,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
if (verification?.proven !== true) {
|
|
993
|
+
refusal = verification?.reason ?? 'the venue read did not prove that nothing was placed';
|
|
994
|
+
} else if (passesDeadline(0)) {
|
|
995
|
+
refusal = 'the venue read ended past the action deadline';
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
receipt = await amendOutcomeUnknownReceipt(input, receipt, {
|
|
999
|
+
rateLimitedDispatches: [...(receipt.record.rateLimitedDispatches ?? []), {
|
|
1000
|
+
attempt,
|
|
1001
|
+
error: message.slice(0, 200),
|
|
1002
|
+
...(verification ? {
|
|
1003
|
+
backoffMs,
|
|
1004
|
+
verification: {
|
|
1005
|
+
proven: verification.proven === true,
|
|
1006
|
+
...(verification.facts ? { facts: verification.facts } : {}),
|
|
1007
|
+
},
|
|
1008
|
+
} : {}),
|
|
1009
|
+
...(refusal ? { refusal: String(refusal).slice(0, 500) } : {}),
|
|
1010
|
+
}],
|
|
1011
|
+
}, 'the rate-limited dispatch');
|
|
1012
|
+
if (refusal) {
|
|
1013
|
+
throw rateLimitedRecoveryError(receipt, { call, attempts: attempt, waitedMs }, receipt.record.rateLimitedDispatches.at(-1).refusal);
|
|
1014
|
+
}
|
|
923
1015
|
}
|
|
924
|
-
throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
|
|
925
1016
|
}
|
|
926
1017
|
}
|
|
927
1018
|
let observation;
|
|
928
1019
|
try {
|
|
929
1020
|
observation = await observe({ resumed: receipt.resumed, receipt: receipt.record });
|
|
930
1021
|
} catch (error) {
|
|
1022
|
+
if (isRateLimited(error)) {
|
|
1023
|
+
throw rateLimitedRecoveryError(receipt, {
|
|
1024
|
+
call: error.call ?? 'the postcondition read',
|
|
1025
|
+
attempts: error.attempts ?? 1,
|
|
1026
|
+
waitedMs: error.waitedMs ?? 0,
|
|
1027
|
+
}, 'the postcondition read could not run, so the dispatched order is not proven');
|
|
1028
|
+
}
|
|
931
1029
|
throw recoveryError(receipt, error instanceof Error ? error.message : String(error));
|
|
932
1030
|
}
|
|
933
1031
|
if (!observation || observation.success !== true) {
|