@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/adapters/extension/ensure-browser.sh +6 -10
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +31 -4
  5. package/adapters/extension/profile-extension-state.cjs +156 -0
  6. package/adapters/extension/readiness.mjs +21 -2
  7. package/adapters/manifest.json +8 -0
  8. package/adapters/mobile/wait-for-bridge.cjs +9 -3
  9. package/dist/adapters/extension/actions.js +2 -1
  10. package/dist/adapters/extension/run.js +6 -0
  11. package/dist/adapters/mobile/action-inputs.js +20 -0
  12. package/dist/adapters/mobile/actions.js +18 -1
  13. package/dist/adapters/mobile/android-screenrecord.js +274 -0
  14. package/dist/adapters/mobile/readiness.js +2 -1
  15. package/dist/adapters/mobile/recording-outcome.js +54 -0
  16. package/dist/adapters/mobile/recording-target.js +11 -0
  17. package/dist/adapters/mobile/run.js +58 -15
  18. package/dist/adapters/mobile/ui.js +96 -2
  19. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  20. package/dist/adapters/perps-order-inputs.js +43 -28
  21. package/dist/adapters.js +4 -1
  22. package/dist/commands/doctor.js +33 -14
  23. package/dist/commands/status-probe.js +2 -1
  24. package/dist/engine.js +11 -2
  25. package/dist/funding-execution-context.js +13 -8
  26. package/dist/host.js +6 -2
  27. package/dist/index.js +3 -0
  28. package/dist/mm-harness-cli.js +4 -2
  29. package/dist/provider.js +72 -0
  30. package/dist/recipe-security.js +1 -0
  31. package/docs/CONTRIBUTING.md +7 -0
  32. package/docs/RECIPES.md +96 -9
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  34. package/docs/cli-contract.md +1 -1
  35. package/library/actions/core/perps/read_account.mjs +4 -2
  36. package/library/actions/core/perps/read_orders.mjs +4 -2
  37. package/library/actions/core/perps/read_positions.mjs +4 -2
  38. package/library/actions/extension/analytics/consent.mjs +7 -2
  39. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  40. package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
  41. package/library/actions/extension/perps/perps.mjs +298 -39
  42. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  43. package/library/actions/extension/perps/set_network.mjs +6 -0
  44. package/library/actions/extension/platform/cdp.mjs +6 -2
  45. package/library/actions/extension/ui/navigate.mjs +8 -0
  46. package/library/actions/mobile/perps/perps.mjs +264 -102
  47. package/library/actions/mobile/perps/place_order.mjs +7 -4
  48. package/library/actions/mobile/platform/bridge.mjs +171 -10
  49. package/library/actions/mobile/wallet/home.mjs +7 -2
  50. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  51. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  52. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  53. package/library/actions/shared/perps/order-policy.mjs +56 -19
  54. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  55. package/library/actions/shared/perps/visible-state.mjs +3 -2
  56. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  57. package/library/manifests/extension.action-manifest.json +36 -5
  58. package/library/manifests/mobile.action-manifest.json +48 -10
  59. package/package.json +14 -9
  60. package/scripts/validate-release-report.mjs +14 -2
  61. 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`), as a 1x
265
- isolated market order with `close_immediately: true`, and the node must
266
- declare `network: mainnet`. The $15 limit bounds the size the controller would
267
- submit: (amount + one size step × price) × (1 + max slippage). Anything else
268
- is refused with `PERPS_MAINNET_ORDER_REFUSED`, naming each broken limit, and
269
- no order is sent.
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 cleanup acts on
334
- whatever network the app is on, and Extension refuses mainnet writes other than
335
- orders and `close_visible_position` in the fixture-policy lane.
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
 
@@ -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. Content-only digests (recipe and dependency digests) stay frozen.
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 metamask.dataCollectionForMarketing !== 'boolean'
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: metamask.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 response = await fetch(endpoint, {
67
- method: 'POST',
68
- headers: { 'content-type': 'application/json' },
69
- body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
70
- signal: AbortSignal.timeout(Number(input.node?.timeout_ms ?? 15_000)),
71
- });
72
- if (!response.ok) throw new Error(`Hyperliquid market request failed with HTTP ${response.status}.`);
73
- const payload = await response.json();
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) a fixture account may run on mainnet.
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 writeSubmissionAcceptance(input, receipt, {
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 writeSubmissionAcceptance(input, receipt, {
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
- async function writeSubmissionAcceptance(input, receipt, acceptance) {
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, 'the receipt changed before submission acceptance was recorded');
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, 'submission acceptance could not be reread');
919
+ throw recoveryError(receipt, `${label} could not be reread`);
897
920
  }
898
921
  return { ...published, resumed: receipt.resumed };
899
922
  }
900
923
 
901
- export async function runGuardedExternalMutation({ input, identity, dispatch, observe, publicationFacts, receiptIo }) {
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
- try {
911
- const acceptance = await dispatch();
912
- if (acceptance?.venueOrderId) receipt = await recordSubmissionAcceptance(input, receipt, acceptance);
913
- } catch (error) {
914
- if (error instanceof ExternalMutationNotDispatchedError) {
915
- await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
916
- throw Object.assign(
917
- new Error(
918
- `External mutation was safely not dispatched (${error.message}). ` +
919
- 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
920
- ),
921
- { knownNotDispatched: true },
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) {