@deeeed/metamask-harness 0.86.1 → 0.87.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 +41 -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 +270 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +103 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +48 -11
- package/dist/adapters/mobile/ui.js +96 -2
- 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/docs/RECIPES.md +62 -0
- 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/close_visible_position.mjs +9 -5
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +142 -24
- package/library/actions/extension/perps/perps.mjs +179 -40
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- 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 +109 -109
- package/library/actions/mobile/platform/bridge.mjs +192 -12
- 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/order-policy.mjs +19 -0
- package/library/actions/shared/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/manifests/extension.action-manifest.json +3 -2
- package/library/manifests/mobile.action-manifest.json +30 -5
- package/package.json +1 -1
- package/site/assets/help-recipes.json +1 -0
|
@@ -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) {
|
|
@@ -831,8 +847,27 @@ export async function recordExternalMutationCleanupCandidate(input, receipt, can
|
|
|
831
847
|
});
|
|
832
848
|
}
|
|
833
849
|
|
|
850
|
+
// A market order placed through the background Perps request reports the venue's
|
|
851
|
+
// filled order itself; there is no observed network request to bind.
|
|
852
|
+
export const BACKGROUND_ACCEPTANCE_SOURCE = 'background-perpsPlaceOrder';
|
|
853
|
+
|
|
854
|
+
function validFilledOrder(filledSize, averagePrice) {
|
|
855
|
+
return /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,8})?$/u.test(String(filledSize)) && Number(filledSize) > 0
|
|
856
|
+
&& /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$/u.test(String(averagePrice)) && Number(averagePrice) > 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
834
859
|
async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
835
860
|
const { venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits, filledSize, averagePrice } = acceptance;
|
|
861
|
+
if (acceptance.source === BACKGROUND_ACCEPTANCE_SOURCE) {
|
|
862
|
+
if (receipt.record.identity.operation !== 'perps-market-order' || receipt.record.identity.provider !== 'hyperliquid'
|
|
863
|
+
|| !/^[1-9][0-9]{0,19}$/u.test(String(venueOrderId)) || status !== 'filled' || size !== filledSize
|
|
864
|
+
|| !validFilledOrder(filledSize, averagePrice)) {
|
|
865
|
+
throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
|
|
866
|
+
}
|
|
867
|
+
return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
|
|
868
|
+
source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
|
|
869
|
+
} }, 'submission acceptance');
|
|
870
|
+
}
|
|
836
871
|
const closing = receipt.record.identity.operation === 'perps-close-position';
|
|
837
872
|
const market = closing || receipt.record.identity.operation === 'perps-market-order';
|
|
838
873
|
const sizing = closing ? receipt.record.facts?.closeSizing
|
|
@@ -849,20 +884,28 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
849
884
|
|| !BASE_UNITS.test(String(limitPriceBaseUnits))) {
|
|
850
885
|
throw recoveryError(receipt, 'submission acceptance has invalid order identity');
|
|
851
886
|
}
|
|
852
|
-
if (market && status === 'filled' && (
|
|
853
|
-
|| !(Number(filledSize) > 0) || Number(filledSize) > Number(size)
|
|
854
|
-
|| !/^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$/u.test(String(averagePrice)) || !(Number(averagePrice) > 0))) {
|
|
887
|
+
if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
|
|
855
888
|
throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
|
|
856
889
|
}
|
|
890
|
+
return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
|
|
891
|
+
venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
|
|
892
|
+
...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
|
|
893
|
+
} }, 'submission acceptance');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Adds fields to a still outcome-unknown receipt: the accepted order, or a 429
|
|
897
|
+
// and the venue read that followed it.
|
|
898
|
+
async function amendOutcomeUnknownReceipt(input, receipt, fields, label) {
|
|
857
899
|
const destination = await receiptDestination(input);
|
|
858
900
|
const current = await readPublished(destination);
|
|
859
901
|
if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
|
|
860
|
-
throw recoveryError(receipt,
|
|
902
|
+
throw recoveryError(receipt, `the receipt changed before ${label} was recorded`);
|
|
903
|
+
}
|
|
904
|
+
const record = { ...current.record, ...fields };
|
|
905
|
+
const bytes = canonical(record);
|
|
906
|
+
if (bytes.length > 12_288 || /secret|password|srp|mnemonic|private.?key/iu.test(bytes)) {
|
|
907
|
+
throw recoveryError(receipt, `the ${label} receipt is unsafe`);
|
|
861
908
|
}
|
|
862
|
-
const record = { ...current.record, acceptance: {
|
|
863
|
-
venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
|
|
864
|
-
...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
|
|
865
|
-
} };
|
|
866
909
|
const staged = await stagedRecord(destination.directory, record);
|
|
867
910
|
try {
|
|
868
911
|
await rename(staged.stagedPath, destination.absolute);
|
|
@@ -872,12 +915,21 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
872
915
|
}
|
|
873
916
|
const published = await readPublished(destination);
|
|
874
917
|
if (!published || canonical(published.record) !== canonical(record)) {
|
|
875
|
-
throw recoveryError(receipt,
|
|
918
|
+
throw recoveryError(receipt, `${label} could not be reread`);
|
|
876
919
|
}
|
|
877
920
|
return { ...published, resumed: receipt.resumed };
|
|
878
921
|
}
|
|
879
922
|
|
|
880
|
-
|
|
923
|
+
/**
|
|
924
|
+
* Publish the receipt, dispatch once, observe the postcondition and complete the receipt.
|
|
925
|
+
* @param rateLimit - Optional retry of a dispatch the venue refused with a 429:
|
|
926
|
+
* { call, deadline, verifyBudgetMs, verifyNotPlaced({ sinceMs }) -> { proven, reason?, facts? }, sleep?, random? }.
|
|
927
|
+
* A retry runs only when the dispatch itself threw the 429 (nothing accepted), the backoff and
|
|
928
|
+
* the venue read fit before the deadline, and verifyNotPlaced then proves from the venue that
|
|
929
|
+
* nothing was placed since the first dispatch. Every 429 is recorded in the receipt with its
|
|
930
|
+
* verification or the reason it was not retried.
|
|
931
|
+
*/
|
|
932
|
+
export async function runGuardedExternalMutation({ input, identity, dispatch, observe, publicationFacts, receiptIo, rateLimit = null }) {
|
|
881
933
|
let receipt = await beginExternalMutation(input, identity, receiptIo, publicationFacts);
|
|
882
934
|
if (receipt.record?.state === 'observed-policy-violation') {
|
|
883
935
|
throw new Error('External mutation receipt records an observed policy violation and is cleanup-only.');
|
|
@@ -886,27 +938,93 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
|
|
|
886
938
|
throw new Error('External mutation receipt state is unsafe.');
|
|
887
939
|
}
|
|
888
940
|
if (!receipt.resumed) {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
)
|
|
941
|
+
const call = rateLimit?.call ?? `${identity.action} dispatch`;
|
|
942
|
+
const sinceMs = Date.now();
|
|
943
|
+
let waitedMs = 0;
|
|
944
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
945
|
+
let returned = false;
|
|
946
|
+
try {
|
|
947
|
+
// A retry must reach the venue before the deadline, its pre-dispatch checks included.
|
|
948
|
+
const acceptance = await dispatch(attempt > 1 ? { deadline: rateLimit.deadline } : {});
|
|
949
|
+
returned = true;
|
|
950
|
+
if (acceptance?.venueOrderId) receipt = await recordSubmissionAcceptance(input, receipt, acceptance);
|
|
951
|
+
break;
|
|
952
|
+
} catch (error) {
|
|
953
|
+
if (error instanceof ExternalMutationNotDispatchedError) {
|
|
954
|
+
await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
|
|
955
|
+
// A retry that was not sent still ends the action on the 429 that called for it.
|
|
956
|
+
const retried = attempt > 1;
|
|
957
|
+
throw Object.assign(
|
|
958
|
+
new Error(
|
|
959
|
+
`${retried ? `${RATE_LIMITED}: ` : ''}External mutation was safely not dispatched (` +
|
|
960
|
+
`${retried ? `${describeRateLimit({ call, attempts: attempt - 1, waitedMs })}; the retry was not sent: ` : ''}` +
|
|
961
|
+
`${error.message}). ` +
|
|
962
|
+
'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
|
|
963
|
+
),
|
|
964
|
+
{ knownNotDispatched: true, ...(retried ? { code: RATE_LIMITED } : {}) },
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
968
|
+
if (returned || !isRateLimited(error)) {
|
|
969
|
+
throw recoveryError(receipt, `pointer dispatch may have occurred (${message})`);
|
|
970
|
+
}
|
|
971
|
+
const deadline = rateLimit?.deadline;
|
|
972
|
+
const passesDeadline = (ms) => Number.isFinite(deadline) && Date.now() + ms >= deadline;
|
|
973
|
+
const backoffMs = attempt > RATE_LIMIT_RETRIES ? 0 : rateLimitBackoffMs(attempt, rateLimit?.random);
|
|
974
|
+
let refusal = !rateLimit?.verifyNotPlaced ? 'this mutation has no venue read that can prove nothing was placed'
|
|
975
|
+
: receipt.record.acceptance ? 'the receipt already records an accepted order'
|
|
976
|
+
: attempt > RATE_LIMIT_RETRIES ? 'its retries are spent'
|
|
977
|
+
: passesDeadline(backoffMs + (rateLimit.verifyBudgetMs ?? 0))
|
|
978
|
+
? `the next ${backoffMs} ms backoff and the venue read would pass the action deadline` : null;
|
|
979
|
+
let verification = null;
|
|
980
|
+
if (!refusal) {
|
|
981
|
+
await (rateLimit.sleep ?? backoffSleep)(backoffMs);
|
|
982
|
+
waitedMs += backoffMs;
|
|
983
|
+
try {
|
|
984
|
+
verification = await rateLimit.verifyNotPlaced({ sinceMs });
|
|
985
|
+
} catch (verifyError) {
|
|
986
|
+
verification = {
|
|
987
|
+
proven: false,
|
|
988
|
+
reason: `the venue read failed (${verifyError instanceof Error ? verifyError.message : String(verifyError)})`,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
if (verification?.proven !== true) {
|
|
992
|
+
refusal = verification?.reason ?? 'the venue read did not prove that nothing was placed';
|
|
993
|
+
} else if (passesDeadline(0)) {
|
|
994
|
+
refusal = 'the venue read ended past the action deadline';
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
receipt = await amendOutcomeUnknownReceipt(input, receipt, {
|
|
998
|
+
rateLimitedDispatches: [...(receipt.record.rateLimitedDispatches ?? []), {
|
|
999
|
+
attempt,
|
|
1000
|
+
error: message.slice(0, 200),
|
|
1001
|
+
...(verification ? {
|
|
1002
|
+
backoffMs,
|
|
1003
|
+
verification: {
|
|
1004
|
+
proven: verification.proven === true,
|
|
1005
|
+
...(verification.facts ? { facts: verification.facts } : {}),
|
|
1006
|
+
},
|
|
1007
|
+
} : {}),
|
|
1008
|
+
...(refusal ? { refusal: String(refusal).slice(0, 500) } : {}),
|
|
1009
|
+
}],
|
|
1010
|
+
}, 'the rate-limited dispatch');
|
|
1011
|
+
if (refusal) {
|
|
1012
|
+
throw rateLimitedRecoveryError(receipt, { call, attempts: attempt, waitedMs }, receipt.record.rateLimitedDispatches.at(-1).refusal);
|
|
1013
|
+
}
|
|
902
1014
|
}
|
|
903
|
-
throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
|
|
904
1015
|
}
|
|
905
1016
|
}
|
|
906
1017
|
let observation;
|
|
907
1018
|
try {
|
|
908
1019
|
observation = await observe({ resumed: receipt.resumed, receipt: receipt.record });
|
|
909
1020
|
} catch (error) {
|
|
1021
|
+
if (isRateLimited(error)) {
|
|
1022
|
+
throw rateLimitedRecoveryError(receipt, {
|
|
1023
|
+
call: error.call ?? 'the postcondition read',
|
|
1024
|
+
attempts: error.attempts ?? 1,
|
|
1025
|
+
waitedMs: error.waitedMs ?? 0,
|
|
1026
|
+
}, 'the postcondition read could not run, so the dispatched order is not proven');
|
|
1027
|
+
}
|
|
910
1028
|
throw recoveryError(receipt, error instanceof Error ? error.message : String(error));
|
|
911
1029
|
}
|
|
912
1030
|
if (!observation || observation.success !== true) {
|
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { dataTestId, marketSymbol, normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
9
9
|
import {
|
|
10
10
|
assertTrustedExternalMutationTarget,
|
|
11
|
+
BACKGROUND_ACCEPTANCE_SOURCE,
|
|
11
12
|
externalMutationNotDispatched,
|
|
12
13
|
readExternalMutationReceipt,
|
|
13
14
|
readPriorMutationReceipt,
|
|
@@ -27,8 +28,11 @@ import {
|
|
|
27
28
|
assertFlatBeforeRoundTrip,
|
|
28
29
|
assertHouseLimitPosition,
|
|
29
30
|
closeImmediatelyRequested,
|
|
31
|
+
marketOrderLot,
|
|
30
32
|
requireHouseNotional,
|
|
31
33
|
} from '../../shared/perps/order-policy.mjs';
|
|
34
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
35
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
32
36
|
import {
|
|
33
37
|
orderIdentityMatches,
|
|
34
38
|
parseOrderCard,
|
|
@@ -343,23 +347,36 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
|
|
|
343
347
|
};
|
|
344
348
|
}
|
|
345
349
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
350
|
+
function hyperliquidOrigin(network) {
|
|
351
|
+
return network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function postHyperliquidInfo(network, body, signal = AbortSignal.timeout(10_000)) {
|
|
355
|
+
return fetch(`${hyperliquidOrigin(network)}/info`, {
|
|
356
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
357
|
+
body: JSON.stringify(body), signal,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The Hyperliquid network and user a receipt's order went to, read from the live
|
|
362
|
+
// app and bound to the receipt's selected-account hash.
|
|
363
|
+
async function receiptVenueUser(input, page, identity, purpose) {
|
|
349
364
|
const { provider, network } = await requirePerpsTradingContext(input, page);
|
|
350
|
-
if (provider !== 'hyperliquid') throw new Error(
|
|
365
|
+
if (provider !== 'hyperliquid') throw new Error(`${purpose} requires the original Hyperliquid provider.\nNext: mm-harness last --json`);
|
|
351
366
|
const wallet = await page.evaluate(readWalletStateExpression());
|
|
352
367
|
const user = String(wallet.selectedAccount?.address ?? '').toLowerCase();
|
|
353
368
|
if (!/^0x[a-f0-9]{40}$/u.test(user)
|
|
354
|
-
|| createHash('sha256').update(user).digest('hex') !==
|
|
355
|
-
throw new Error(
|
|
369
|
+
|| createHash('sha256').update(user).digest('hex') !== identity.selectedIdentitySha256) {
|
|
370
|
+
throw new Error(`${purpose} wallet differs from the placement receipt.\nNext: mm-harness last --json`);
|
|
356
371
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
372
|
+
return { network, user };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
|
|
376
|
+
const accepted = placement.record?.acceptance;
|
|
377
|
+
if (!accepted?.venueOrderId) return null;
|
|
378
|
+
const { network, user } = await receiptVenueUser(input, page, placement.record.identity, 'Accepted-order status');
|
|
379
|
+
const response = await postHyperliquidInfo(network, { type: 'orderStatus', user, oid: Number(accepted.venueOrderId) });
|
|
363
380
|
if (!response.ok) throw new Error('Accepted-order status request failed.\nNext: mm-harness last --json');
|
|
364
381
|
const result = await response.json();
|
|
365
382
|
if (result.status === 'unknownOid') throw new Error('Accepted venue order status is unavailable.\nNext: mm-harness last --json');
|
|
@@ -669,7 +686,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
|
|
|
669
686
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
670
687
|
throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
|
|
671
688
|
}
|
|
672
|
-
const price = await page.evaluate(String.raw`(async () => {
|
|
689
|
+
const price = await withRateLimitRetry('perpsGetMarketDataWithPrices', () => page.evaluate(String.raw`(async () => {
|
|
673
690
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
674
691
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
675
692
|
const symbol = ${JSON.stringify(symbol)};
|
|
@@ -707,7 +724,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
|
|
|
707
724
|
const line = body.split('\n').find((text) => /\$[0-9][0-9,]*(?:\.[0-9]+)?/.test(text));
|
|
708
725
|
const fromBody = parsePrice(line);
|
|
709
726
|
return fromBody ? { price: fromBody, source: 'document-body-price-line' } : null;
|
|
710
|
-
})()`);
|
|
727
|
+
})()`), { deadline: page.actionDeadline });
|
|
711
728
|
const numericPrice = Number(price?.price ?? price);
|
|
712
729
|
if (!Number.isFinite(numericPrice) || numericPrice <= 0) throw new Error(`Unable to determine current Perps price for ${symbol}.`);
|
|
713
730
|
return numericPrice;
|
|
@@ -780,8 +797,33 @@ function backgroundPositionObservation(position) {
|
|
|
780
797
|
return { market, side, leverage, size: String(position?.size ?? position?.szi), marginBaseUnits };
|
|
781
798
|
}
|
|
782
799
|
|
|
783
|
-
|
|
800
|
+
// Whole size steps in a size, or null when it is off the step grid.
|
|
801
|
+
function sizeLots(size, sizeDecimals) {
|
|
802
|
+
const scaled = Math.abs(Number(size)) * 10 ** sizeDecimals;
|
|
803
|
+
const lots = Math.round(scaled);
|
|
804
|
+
return Number.isSafeInteger(lots) && lots > 0 && Math.abs(scaled - lots) < 1e-6 ? lots : null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs, acceptance) {
|
|
784
808
|
const deadline = Date.now() + timeoutMs;
|
|
809
|
+
const lot = expected.orderType === 'market' ? expected.marketLot : null;
|
|
810
|
+
// A venue-reported fill fixes the exact size; without one the size may be a step
|
|
811
|
+
// off the lot computed here, since the controller sizes at its own fresh price.
|
|
812
|
+
const acceptedLots = lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
|
|
813
|
+
? sizeLots(acceptance.filledSize, lot.sizeDecimals) : null;
|
|
814
|
+
if (lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
|
|
815
|
+
&& (acceptedLots == null || Math.abs(acceptedLots - lot.lots) > 1)) {
|
|
816
|
+
throw new Error('Background Perps market fill size is not within one size step of the requested amount.');
|
|
817
|
+
}
|
|
818
|
+
const marginToleranceBaseUnits = lot
|
|
819
|
+
? `${BigInt(expected.roundingToleranceBaseUnits) + BigInt(lot.stepMarginBaseUnits)}`
|
|
820
|
+
: expected.roundingToleranceBaseUnits;
|
|
821
|
+
const reported = acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE;
|
|
822
|
+
const sizeMatches = (size) => {
|
|
823
|
+
if (!lot) return !reported || Math.abs(Number(size)) === Number(acceptance.filledSize);
|
|
824
|
+
const observed = sizeLots(size, lot.sizeDecimals);
|
|
825
|
+
return observed != null && (acceptedLots != null ? observed === acceptedLots : Math.abs(observed - lot.lots) <= 1);
|
|
826
|
+
};
|
|
785
827
|
while (Date.now() <= deadline) {
|
|
786
828
|
if (expected.orderType === 'limit') {
|
|
787
829
|
const matches = (await readOpenOrders(page))
|
|
@@ -798,12 +840,20 @@ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
|
|
|
798
840
|
&& position.market === expected.market
|
|
799
841
|
&& position.side === expected.side
|
|
800
842
|
&& position.leverage === expected.leverage
|
|
843
|
+
&& sizeMatches(position.size)
|
|
801
844
|
&& withinBaseUnitTolerance(
|
|
802
845
|
position.marginBaseUnits,
|
|
803
846
|
expected.amountBaseUnits,
|
|
804
|
-
|
|
847
|
+
marginToleranceBaseUnits,
|
|
805
848
|
));
|
|
806
|
-
if (matches.length === 1)
|
|
849
|
+
if (matches.length === 1) {
|
|
850
|
+
// The step tolerance widens the identity match, never the exposure the run allowed.
|
|
851
|
+
if (expected.maximumNotionalBaseUnits != null
|
|
852
|
+
&& BigInt(matches[0].marginBaseUnits) * BigInt(matches[0].leverage) > BigInt(expected.maximumNotionalBaseUnits)) {
|
|
853
|
+
throw new Error('Background Perps position exposure exceeds the runner-trusted operation cap.');
|
|
854
|
+
}
|
|
855
|
+
return { success: true, position: matches[0] };
|
|
856
|
+
}
|
|
807
857
|
if (matches.length > 1) throw new Error('Background position postcondition matched more than one position.');
|
|
808
858
|
}
|
|
809
859
|
await sleep(250);
|
|
@@ -870,12 +920,17 @@ async function dispatchBackgroundPlace(page, {
|
|
|
870
920
|
};
|
|
871
921
|
const result = await request('perpsPlaceOrder', [orderParams]);
|
|
872
922
|
if (!result || result.success !== true) throw new Error(result?.error || 'perpsPlaceOrder failed.');
|
|
873
|
-
return {
|
|
923
|
+
return {
|
|
924
|
+
success: true,
|
|
925
|
+
orderId: result.orderId == null ? null : String(result.orderId),
|
|
926
|
+
filledSize: result.filledSize == null ? null : String(result.filledSize),
|
|
927
|
+
averagePrice: result.averagePrice == null ? null : String(result.averagePrice),
|
|
928
|
+
};
|
|
874
929
|
})()`, { awaitPromise: true });
|
|
875
930
|
}
|
|
876
931
|
|
|
877
932
|
async function readOpenOrdersState(page) {
|
|
878
|
-
const state = await page.evaluate(`(async () => {
|
|
933
|
+
const state = await withRateLimitRetry('perpsGetOpenOrders', () => page.evaluate(`(async () => {
|
|
879
934
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
880
935
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
881
936
|
const cached = manager?.orders?.cache;
|
|
@@ -893,13 +948,13 @@ async function readOpenOrdersState(page) {
|
|
|
893
948
|
connected: Boolean(manager?.orders?.isConnected),
|
|
894
949
|
orders: Array.isArray(cached) ? cached : [],
|
|
895
950
|
};
|
|
896
|
-
})()`);
|
|
951
|
+
})()`), { deadline: page.actionDeadline });
|
|
897
952
|
if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
|
|
898
953
|
return state;
|
|
899
954
|
}
|
|
900
955
|
|
|
901
956
|
async function readAccountState(page) {
|
|
902
|
-
const state = await page.evaluate(`(async () => {
|
|
957
|
+
const state = await withRateLimitRetry('perpsGetAccountState', () => page.evaluate(`(async () => {
|
|
903
958
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
904
959
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
905
960
|
const cached = manager?.account?.cache;
|
|
@@ -917,7 +972,7 @@ async function readAccountState(page) {
|
|
|
917
972
|
connected: Boolean(manager?.account?.isConnected),
|
|
918
973
|
account: cached && typeof cached === 'object' ? cached : null,
|
|
919
974
|
};
|
|
920
|
-
})()`);
|
|
975
|
+
})()`), { deadline: page.actionDeadline });
|
|
921
976
|
if (!state.available) throw new Error('Perps account state is unavailable from both background and stream manager cache.');
|
|
922
977
|
return state.account;
|
|
923
978
|
}
|
|
@@ -1202,6 +1257,52 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
|
|
|
1202
1257
|
throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
|
|
1203
1258
|
}
|
|
1204
1259
|
|
|
1260
|
+
// Fills and open orders up to this long before the first dispatch count as this
|
|
1261
|
+
// order's, so a local clock behind the venue's cannot hide one.
|
|
1262
|
+
const VENUE_CLOCK_SKEW_MS = 30_000;
|
|
1263
|
+
// All three venue reads together; a retry starts only if they fit before its deadline.
|
|
1264
|
+
const VENUE_VERIFY_TIMEOUT_MS = 5_000;
|
|
1265
|
+
|
|
1266
|
+
// After a 429 on a background placement: Hyperliquid's own account state, not the
|
|
1267
|
+
// extension's cache, shows no open order and no fill in the market since the first
|
|
1268
|
+
// dispatch, and for a market order no position on its side (the pre-order baseline
|
|
1269
|
+
// had none). Open orders are read before fills, so an order of ours that fills
|
|
1270
|
+
// between the two reads still shows in one of them. Anything else, or a failed
|
|
1271
|
+
// read, leaves the outcome unproven.
|
|
1272
|
+
async function verifyNothingPlacedOnVenue(input, page, identity, sinceMs) {
|
|
1273
|
+
const coin = identity.market;
|
|
1274
|
+
if (coin.includes(':')) {
|
|
1275
|
+
return { proven: false, reason: `the venue read covers only the main Hyperliquid dex, not ${coin}` };
|
|
1276
|
+
}
|
|
1277
|
+
const { network, user } = await receiptVenueUser(input, page, identity, 'Rate-limit verification');
|
|
1278
|
+
const startTime = sinceMs - VENUE_CLOCK_SKEW_MS;
|
|
1279
|
+
const signal = AbortSignal.timeout(VENUE_VERIFY_TIMEOUT_MS);
|
|
1280
|
+
const read = async (body) => {
|
|
1281
|
+
const response = await postHyperliquidInfo(network, body, signal);
|
|
1282
|
+
if (!response.ok) throw new Error(`Hyperliquid ${body.type} answered HTTP ${response.status}`);
|
|
1283
|
+
return response.json();
|
|
1284
|
+
};
|
|
1285
|
+
const orders = await read({ type: 'openOrders', user });
|
|
1286
|
+
const fills = await read({ type: 'userFillsByTime', user, startTime });
|
|
1287
|
+
const account = await read({ type: 'clearinghouseState', user });
|
|
1288
|
+
if (!Array.isArray(fills) || !Array.isArray(orders) || !Array.isArray(account?.assetPositions)) {
|
|
1289
|
+
return { proven: false, reason: 'the Hyperliquid account reads returned an unexpected shape' };
|
|
1290
|
+
}
|
|
1291
|
+
const size = Number(account.assetPositions.find((entry) => entry?.position?.coin === coin)?.position?.szi ?? 0);
|
|
1292
|
+
if (!Number.isFinite(size)) return { proven: false, reason: `the Hyperliquid ${coin} position size is unreadable` };
|
|
1293
|
+
const facts = {
|
|
1294
|
+
source: 'hyperliquid-info',
|
|
1295
|
+
startTime,
|
|
1296
|
+
openOrders: orders.filter((order) => order?.coin === coin && !(Number(order?.timestamp) < startTime)).length,
|
|
1297
|
+
fills: fills.filter((fill) => fill?.coin === coin && !(Number(fill?.time) < startTime)).length,
|
|
1298
|
+
sameSidePosition: identity.orderType === 'market' && (identity.side === 'long' ? size > 0 : size < 0),
|
|
1299
|
+
};
|
|
1300
|
+
if (facts.openOrders > 0 || facts.fills > 0 || facts.sameSidePosition) {
|
|
1301
|
+
return { proven: false, reason: `Hyperliquid shows ${coin} activity since the first dispatch`, facts };
|
|
1302
|
+
}
|
|
1303
|
+
return { proven: true, facts };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1205
1306
|
async function placeOrderThroughBackground(input, page, {
|
|
1206
1307
|
amount,
|
|
1207
1308
|
bound = null,
|
|
@@ -1246,20 +1347,44 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1246
1347
|
}
|
|
1247
1348
|
}
|
|
1248
1349
|
}
|
|
1350
|
+
if (!existing && expected.marketLot
|
|
1351
|
+
&& BigInt(expected.marketLot.notionalBaseUnits) > BigInt(expected.maximumNotionalBaseUnits)) {
|
|
1352
|
+
throw new Error('Rounded Perps market lot exceeds the runner-trusted operation cap.');
|
|
1353
|
+
}
|
|
1354
|
+
// A resume checks the fill against the lot sized at publication, not one
|
|
1355
|
+
// recomputed at a price that has since moved.
|
|
1356
|
+
const marketLot = existing?.record?.facts?.marketLot ?? expected.marketLot ?? null;
|
|
1249
1357
|
const mutation = await runGuardedExternalMutation({
|
|
1250
1358
|
input,
|
|
1251
1359
|
identity,
|
|
1252
|
-
|
|
1253
|
-
|
|
1360
|
+
rateLimit: {
|
|
1361
|
+
call: 'perpsPlaceOrder',
|
|
1362
|
+
// A retry leaves the postcondition its timeout_ms and 5 s before the runner's process limit.
|
|
1363
|
+
deadline: liveAdapterDeadline(input.node, Number(input.node?.timeout_ms ?? 30_000) + 5_000),
|
|
1364
|
+
verifyBudgetMs: VENUE_VERIFY_TIMEOUT_MS,
|
|
1365
|
+
verifyNotPlaced: ({ sinceMs }) => verifyNothingPlacedOnVenue(input, page, identity, sinceMs),
|
|
1366
|
+
},
|
|
1367
|
+
// The pre-order balance lets a receipt-bound close reconcile its final balance.
|
|
1368
|
+
publicationFacts: {
|
|
1369
|
+
balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
|
|
1370
|
+
baselineOrderIds: [...baselineIds],
|
|
1371
|
+
fundingBalance,
|
|
1372
|
+
...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
|
|
1373
|
+
},
|
|
1374
|
+
dispatch: async ({ deadline = null } = {}) => {
|
|
1254
1375
|
try {
|
|
1376
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
1255
1377
|
await requireCurrentVisibleFundingBalance(
|
|
1256
1378
|
input,
|
|
1257
1379
|
page,
|
|
1258
|
-
|
|
1380
|
+
deadline == null ? timeoutMs : Math.max(0, Math.min(timeoutMs, deadline - Date.now())),
|
|
1259
1381
|
fundingBalance.observedBaseUnits,
|
|
1260
1382
|
);
|
|
1261
1383
|
await requirePerpsTradingContext(input, page);
|
|
1262
1384
|
assertTrustedExternalMutationTarget(input, page, identity);
|
|
1385
|
+
if (deadline != null && Date.now() >= deadline) {
|
|
1386
|
+
throw new Error('it reached the action deadline before dispatch');
|
|
1387
|
+
}
|
|
1263
1388
|
} catch (error) {
|
|
1264
1389
|
throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
|
|
1265
1390
|
}
|
|
@@ -1278,13 +1403,26 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1278
1403
|
if (accepted?.changed) {
|
|
1279
1404
|
throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
|
|
1280
1405
|
}
|
|
1406
|
+
// The venue's filled order ID goes into the receipt, so cleanup can prove the
|
|
1407
|
+
// position is this order's own.
|
|
1408
|
+
if (orderType === 'market' && accepted?.orderId != null && accepted.filledSize != null && accepted.averagePrice != null) {
|
|
1409
|
+
return {
|
|
1410
|
+
source: BACKGROUND_ACCEPTANCE_SOURCE,
|
|
1411
|
+
venueOrderId: accepted.orderId,
|
|
1412
|
+
status: 'filled',
|
|
1413
|
+
size: accepted.filledSize,
|
|
1414
|
+
filledSize: accepted.filledSize,
|
|
1415
|
+
averagePrice: accepted.averagePrice,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1281
1418
|
return accepted;
|
|
1282
1419
|
},
|
|
1283
|
-
observe: async () => observeBackgroundPlace(
|
|
1420
|
+
observe: async ({ receipt }) => observeBackgroundPlace(
|
|
1284
1421
|
page,
|
|
1285
|
-
expected,
|
|
1422
|
+
{ ...expected, marketLot },
|
|
1286
1423
|
baselineIds,
|
|
1287
1424
|
Number(input.node?.timeout_ms ?? 30_000),
|
|
1425
|
+
receipt?.acceptance,
|
|
1288
1426
|
),
|
|
1289
1427
|
});
|
|
1290
1428
|
return {
|
|
@@ -1501,12 +1639,8 @@ export async function observeHyperliquidSubmission(page, expected) {
|
|
|
1501
1639
|
if (expected.provider !== 'hyperliquid' || !['market', 'limit'].includes(expected.orderType)) {
|
|
1502
1640
|
throw new Error('Exact visible submission observation requires a Hyperliquid market or limit order.');
|
|
1503
1641
|
}
|
|
1504
|
-
const origin = expected.network
|
|
1505
|
-
|
|
1506
|
-
const metadataResponse = await fetch(`${origin}/info`, {
|
|
1507
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1508
|
-
body: JSON.stringify({ type: 'meta' }), signal: AbortSignal.timeout(10_000),
|
|
1509
|
-
});
|
|
1642
|
+
const origin = hyperliquidOrigin(expected.network);
|
|
1643
|
+
const metadataResponse = await postHyperliquidInfo(expected.network, { type: 'meta' });
|
|
1510
1644
|
if (!metadataResponse.ok) throw new Error('Hyperliquid market metadata is unavailable before submission.');
|
|
1511
1645
|
const metadata = await metadataResponse.json();
|
|
1512
1646
|
const asset = metadata.universe?.findIndex((market) => market.name === expected.market);
|
|
@@ -1676,8 +1810,9 @@ export async function placeOrder(input) {
|
|
|
1676
1810
|
const slippage = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300) / 10_000;
|
|
1677
1811
|
const margin = Number(amount);
|
|
1678
1812
|
if (szDecimals == null) throw new Error(`close_immediately cannot read the ${symbol} size decimals; not dispatched.`);
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1813
|
+
const lot = marketOrderLot({ amount, leverage, price, szDecimals });
|
|
1814
|
+
const tolerance = lot ? Number(lot.stepMarginBaseUnits) + Math.ceil(margin * slippage * 1_000_000) + 10_000 : Number.NaN;
|
|
1815
|
+
if (!Number.isFinite(tolerance) || tolerance > 1_000_000) {
|
|
1681
1816
|
throw new Error(`close_immediately cannot prove a ${symbol} fill within $1 (size step or slippage too coarse); pass identity_rounding_tolerance_base_units or choose another market.`);
|
|
1682
1817
|
}
|
|
1683
1818
|
// The receipt identity must not move with the price, so a resume finds it:
|
|
@@ -1743,15 +1878,18 @@ export async function placeOrder(input) {
|
|
|
1743
1878
|
symbol,
|
|
1744
1879
|
input.node?.current_price ?? input.node?.currentPrice,
|
|
1745
1880
|
);
|
|
1881
|
+
const szDecimals = lane.network === 'mainnet' || orderType === 'market'
|
|
1882
|
+
? await readSizeDecimals(page, symbol) : null;
|
|
1746
1883
|
houseNotional = requireHouseNotional({
|
|
1747
1884
|
network: lane.network,
|
|
1748
1885
|
symbol,
|
|
1749
1886
|
amount,
|
|
1750
1887
|
leverage,
|
|
1751
1888
|
price: resolvedPrice,
|
|
1752
|
-
szDecimals
|
|
1889
|
+
szDecimals,
|
|
1753
1890
|
maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
|
|
1754
1891
|
});
|
|
1892
|
+
const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
|
|
1755
1893
|
const backgroundLimitPrice = orderType === 'limit'
|
|
1756
1894
|
? resolveLimitPrice(input, isBuy, resolvedPrice)
|
|
1757
1895
|
: null;
|
|
@@ -1764,6 +1902,7 @@ export async function placeOrder(input) {
|
|
|
1764
1902
|
expected: {
|
|
1765
1903
|
...expectedVisibleIdentity,
|
|
1766
1904
|
limitPriceBaseUnits: backgroundLimitPrice == null ? null : parseUsdBaseUnits(backgroundLimitPrice),
|
|
1905
|
+
...(marketLot ? { marketLot } : {}),
|
|
1767
1906
|
},
|
|
1768
1907
|
identity,
|
|
1769
1908
|
isBuy,
|
|
@@ -2107,7 +2246,7 @@ export async function placeOrder(input) {
|
|
|
2107
2246
|
// read in one evaluation with the context guard before, between and after the
|
|
2108
2247
|
// reads, so a switch to another account's book and back can't pass as flat.
|
|
2109
2248
|
async function readBoundBook(page, bound) {
|
|
2110
|
-
const book = await page.evaluate(`(async () => {
|
|
2249
|
+
const book = await withRateLimitRetry('perpsGetPositions/perpsGetOpenOrders', () => page.evaluate(`(async () => {
|
|
2111
2250
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
2112
2251
|
if (typeof request !== 'function') return { unavailable: true };
|
|
2113
2252
|
const guard = async () => { ${boundContextGuard(bound)} return null; };
|
|
@@ -2117,7 +2256,7 @@ async function readBoundBook(page, bound) {
|
|
|
2117
2256
|
const orders = await request('perpsGetOpenOrders', []);
|
|
2118
2257
|
if (await guard()) return { changed: true };
|
|
2119
2258
|
return { positions, orders };
|
|
2120
|
-
})()`);
|
|
2259
|
+
})()`), { deadline: page.actionDeadline });
|
|
2121
2260
|
if (book?.changed) {
|
|
2122
2261
|
throw Object.assign(
|
|
2123
2262
|
new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
|
|
@@ -2131,9 +2270,9 @@ async function readBoundBook(page, bound) {
|
|
|
2131
2270
|
}
|
|
2132
2271
|
|
|
2133
2272
|
async function readSizeDecimals(page, symbol) {
|
|
2134
|
-
const markets = await page.evaluate(
|
|
2273
|
+
const markets = await withRateLimitRetry('perpsGetMarkets', () => page.evaluate(
|
|
2135
2274
|
`globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
|
|
2136
|
-
);
|
|
2275
|
+
), { deadline: page.actionDeadline });
|
|
2137
2276
|
const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
|
|
2138
2277
|
return market?.szDecimals ?? null;
|
|
2139
2278
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import { normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
6
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
6
7
|
|
|
7
8
|
const ALL_SECTIONS = ['account', 'markets', 'positions', 'orders', 'history', 'watchlist'];
|
|
8
9
|
const DEFAULT_HISTORY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -533,8 +534,11 @@ export async function readSnapshot(input, testOptions = {}) {
|
|
|
533
534
|
const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
534
535
|
const samples = [];
|
|
535
536
|
for (let index = 0; index < options.sampling.count; index += 1) {
|
|
536
|
-
|
|
537
|
-
samples.push(
|
|
537
|
+
// A section a 429 left unavailable fails normalization; the sample reruns.
|
|
538
|
+
samples.push(await withRateLimitRetry('Perps snapshot', async () => normalizeSnapshotResult(
|
|
539
|
+
await page.evaluate(snapshotRequestExpression(options)),
|
|
540
|
+
options,
|
|
541
|
+
), { sleep: pause, deadline: page.actionDeadline }));
|
|
538
542
|
if (index + 1 < options.sampling.count) await pause(options.sampling.intervalMs);
|
|
539
543
|
}
|
|
540
544
|
const sections = samples.at(-1);
|