@deeeed/metamask-harness 0.86.2 → 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 +26 -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/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +120 -23
- package/library/actions/extension/perps/perps.mjs +98 -30
- 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 +106 -92
- 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/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/manifests/extension.action-manifest.json +2 -1
- 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) {
|
|
@@ -848,9 +864,9 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
848
864
|
|| !validFilledOrder(filledSize, averagePrice)) {
|
|
849
865
|
throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
|
|
850
866
|
}
|
|
851
|
-
return
|
|
867
|
+
return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
|
|
852
868
|
source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
|
|
853
|
-
});
|
|
869
|
+
} }, 'submission acceptance');
|
|
854
870
|
}
|
|
855
871
|
const closing = receipt.record.identity.operation === 'perps-close-position';
|
|
856
872
|
const market = closing || receipt.record.identity.operation === 'perps-market-order';
|
|
@@ -871,19 +887,25 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
871
887
|
if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
|
|
872
888
|
throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
|
|
873
889
|
}
|
|
874
|
-
return
|
|
890
|
+
return amendOutcomeUnknownReceipt(input, receipt, { acceptance: {
|
|
875
891
|
venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
|
|
876
892
|
...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
|
|
877
|
-
});
|
|
893
|
+
} }, 'submission acceptance');
|
|
878
894
|
}
|
|
879
895
|
|
|
880
|
-
|
|
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) {
|
|
881
899
|
const destination = await receiptDestination(input);
|
|
882
900
|
const current = await readPublished(destination);
|
|
883
901
|
if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
|
|
884
|
-
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`);
|
|
885
908
|
}
|
|
886
|
-
const record = { ...current.record, acceptance };
|
|
887
909
|
const staged = await stagedRecord(destination.directory, record);
|
|
888
910
|
try {
|
|
889
911
|
await rename(staged.stagedPath, destination.absolute);
|
|
@@ -893,12 +915,21 @@ async function writeSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
893
915
|
}
|
|
894
916
|
const published = await readPublished(destination);
|
|
895
917
|
if (!published || canonical(published.record) !== canonical(record)) {
|
|
896
|
-
throw recoveryError(receipt,
|
|
918
|
+
throw recoveryError(receipt, `${label} could not be reread`);
|
|
897
919
|
}
|
|
898
920
|
return { ...published, resumed: receipt.resumed };
|
|
899
921
|
}
|
|
900
922
|
|
|
901
|
-
|
|
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 }) {
|
|
902
933
|
let receipt = await beginExternalMutation(input, identity, receiptIo, publicationFacts);
|
|
903
934
|
if (receipt.record?.state === 'observed-policy-violation') {
|
|
904
935
|
throw new Error('External mutation receipt records an observed policy violation and is cleanup-only.');
|
|
@@ -907,27 +938,93 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
|
|
|
907
938
|
throw new Error('External mutation receipt state is unsafe.');
|
|
908
939
|
}
|
|
909
940
|
if (!receipt.resumed) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
)
|
|
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
|
+
}
|
|
923
1014
|
}
|
|
924
|
-
throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
|
|
925
1015
|
}
|
|
926
1016
|
}
|
|
927
1017
|
let observation;
|
|
928
1018
|
try {
|
|
929
1019
|
observation = await observe({ resumed: receipt.resumed, receipt: receipt.record });
|
|
930
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
|
+
}
|
|
931
1028
|
throw recoveryError(receipt, error instanceof Error ? error.message : String(error));
|
|
932
1029
|
}
|
|
933
1030
|
if (!observation || observation.success !== true) {
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
marketOrderLot,
|
|
32
32
|
requireHouseNotional,
|
|
33
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';
|
|
34
36
|
import {
|
|
35
37
|
orderIdentityMatches,
|
|
36
38
|
parseOrderCard,
|
|
@@ -345,23 +347,36 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
|
|
|
345
347
|
};
|
|
346
348
|
}
|
|
347
349
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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) {
|
|
351
364
|
const { provider, network } = await requirePerpsTradingContext(input, page);
|
|
352
|
-
if (provider !== 'hyperliquid') throw new Error(
|
|
365
|
+
if (provider !== 'hyperliquid') throw new Error(`${purpose} requires the original Hyperliquid provider.\nNext: mm-harness last --json`);
|
|
353
366
|
const wallet = await page.evaluate(readWalletStateExpression());
|
|
354
367
|
const user = String(wallet.selectedAccount?.address ?? '').toLowerCase();
|
|
355
368
|
if (!/^0x[a-f0-9]{40}$/u.test(user)
|
|
356
|
-
|| createHash('sha256').update(user).digest('hex') !==
|
|
357
|
-
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`);
|
|
358
371
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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) });
|
|
365
380
|
if (!response.ok) throw new Error('Accepted-order status request failed.\nNext: mm-harness last --json');
|
|
366
381
|
const result = await response.json();
|
|
367
382
|
if (result.status === 'unknownOid') throw new Error('Accepted venue order status is unavailable.\nNext: mm-harness last --json');
|
|
@@ -671,7 +686,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
|
|
|
671
686
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
672
687
|
throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
|
|
673
688
|
}
|
|
674
|
-
const price = await page.evaluate(String.raw`(async () => {
|
|
689
|
+
const price = await withRateLimitRetry('perpsGetMarketDataWithPrices', () => page.evaluate(String.raw`(async () => {
|
|
675
690
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
676
691
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
677
692
|
const symbol = ${JSON.stringify(symbol)};
|
|
@@ -709,7 +724,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
|
|
|
709
724
|
const line = body.split('\n').find((text) => /\$[0-9][0-9,]*(?:\.[0-9]+)?/.test(text));
|
|
710
725
|
const fromBody = parsePrice(line);
|
|
711
726
|
return fromBody ? { price: fromBody, source: 'document-body-price-line' } : null;
|
|
712
|
-
})()`);
|
|
727
|
+
})()`), { deadline: page.actionDeadline });
|
|
713
728
|
const numericPrice = Number(price?.price ?? price);
|
|
714
729
|
if (!Number.isFinite(numericPrice) || numericPrice <= 0) throw new Error(`Unable to determine current Perps price for ${symbol}.`);
|
|
715
730
|
return numericPrice;
|
|
@@ -915,7 +930,7 @@ async function dispatchBackgroundPlace(page, {
|
|
|
915
930
|
}
|
|
916
931
|
|
|
917
932
|
async function readOpenOrdersState(page) {
|
|
918
|
-
const state = await page.evaluate(`(async () => {
|
|
933
|
+
const state = await withRateLimitRetry('perpsGetOpenOrders', () => page.evaluate(`(async () => {
|
|
919
934
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
920
935
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
921
936
|
const cached = manager?.orders?.cache;
|
|
@@ -933,13 +948,13 @@ async function readOpenOrdersState(page) {
|
|
|
933
948
|
connected: Boolean(manager?.orders?.isConnected),
|
|
934
949
|
orders: Array.isArray(cached) ? cached : [],
|
|
935
950
|
};
|
|
936
|
-
})()`);
|
|
951
|
+
})()`), { deadline: page.actionDeadline });
|
|
937
952
|
if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
|
|
938
953
|
return state;
|
|
939
954
|
}
|
|
940
955
|
|
|
941
956
|
async function readAccountState(page) {
|
|
942
|
-
const state = await page.evaluate(`(async () => {
|
|
957
|
+
const state = await withRateLimitRetry('perpsGetAccountState', () => page.evaluate(`(async () => {
|
|
943
958
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
944
959
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
945
960
|
const cached = manager?.account?.cache;
|
|
@@ -957,7 +972,7 @@ async function readAccountState(page) {
|
|
|
957
972
|
connected: Boolean(manager?.account?.isConnected),
|
|
958
973
|
account: cached && typeof cached === 'object' ? cached : null,
|
|
959
974
|
};
|
|
960
|
-
})()`);
|
|
975
|
+
})()`), { deadline: page.actionDeadline });
|
|
961
976
|
if (!state.available) throw new Error('Perps account state is unavailable from both background and stream manager cache.');
|
|
962
977
|
return state.account;
|
|
963
978
|
}
|
|
@@ -1242,6 +1257,52 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
|
|
|
1242
1257
|
throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
|
|
1243
1258
|
}
|
|
1244
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
|
+
|
|
1245
1306
|
async function placeOrderThroughBackground(input, page, {
|
|
1246
1307
|
amount,
|
|
1247
1308
|
bound = null,
|
|
@@ -1296,6 +1357,13 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1296
1357
|
const mutation = await runGuardedExternalMutation({
|
|
1297
1358
|
input,
|
|
1298
1359
|
identity,
|
|
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
|
+
},
|
|
1299
1367
|
// The pre-order balance lets a receipt-bound close reconcile its final balance.
|
|
1300
1368
|
publicationFacts: {
|
|
1301
1369
|
balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
|
|
@@ -1303,16 +1371,20 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1303
1371
|
fundingBalance,
|
|
1304
1372
|
...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
|
|
1305
1373
|
},
|
|
1306
|
-
dispatch: async () => {
|
|
1374
|
+
dispatch: async ({ deadline = null } = {}) => {
|
|
1307
1375
|
try {
|
|
1376
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
1308
1377
|
await requireCurrentVisibleFundingBalance(
|
|
1309
1378
|
input,
|
|
1310
1379
|
page,
|
|
1311
|
-
|
|
1380
|
+
deadline == null ? timeoutMs : Math.max(0, Math.min(timeoutMs, deadline - Date.now())),
|
|
1312
1381
|
fundingBalance.observedBaseUnits,
|
|
1313
1382
|
);
|
|
1314
1383
|
await requirePerpsTradingContext(input, page);
|
|
1315
1384
|
assertTrustedExternalMutationTarget(input, page, identity);
|
|
1385
|
+
if (deadline != null && Date.now() >= deadline) {
|
|
1386
|
+
throw new Error('it reached the action deadline before dispatch');
|
|
1387
|
+
}
|
|
1316
1388
|
} catch (error) {
|
|
1317
1389
|
throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
|
|
1318
1390
|
}
|
|
@@ -1567,12 +1639,8 @@ export async function observeHyperliquidSubmission(page, expected) {
|
|
|
1567
1639
|
if (expected.provider !== 'hyperliquid' || !['market', 'limit'].includes(expected.orderType)) {
|
|
1568
1640
|
throw new Error('Exact visible submission observation requires a Hyperliquid market or limit order.');
|
|
1569
1641
|
}
|
|
1570
|
-
const origin = expected.network
|
|
1571
|
-
|
|
1572
|
-
const metadataResponse = await fetch(`${origin}/info`, {
|
|
1573
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1574
|
-
body: JSON.stringify({ type: 'meta' }), signal: AbortSignal.timeout(10_000),
|
|
1575
|
-
});
|
|
1642
|
+
const origin = hyperliquidOrigin(expected.network);
|
|
1643
|
+
const metadataResponse = await postHyperliquidInfo(expected.network, { type: 'meta' });
|
|
1576
1644
|
if (!metadataResponse.ok) throw new Error('Hyperliquid market metadata is unavailable before submission.');
|
|
1577
1645
|
const metadata = await metadataResponse.json();
|
|
1578
1646
|
const asset = metadata.universe?.findIndex((market) => market.name === expected.market);
|
|
@@ -2178,7 +2246,7 @@ export async function placeOrder(input) {
|
|
|
2178
2246
|
// read in one evaluation with the context guard before, between and after the
|
|
2179
2247
|
// reads, so a switch to another account's book and back can't pass as flat.
|
|
2180
2248
|
async function readBoundBook(page, bound) {
|
|
2181
|
-
const book = await page.evaluate(`(async () => {
|
|
2249
|
+
const book = await withRateLimitRetry('perpsGetPositions/perpsGetOpenOrders', () => page.evaluate(`(async () => {
|
|
2182
2250
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
2183
2251
|
if (typeof request !== 'function') return { unavailable: true };
|
|
2184
2252
|
const guard = async () => { ${boundContextGuard(bound)} return null; };
|
|
@@ -2188,7 +2256,7 @@ async function readBoundBook(page, bound) {
|
|
|
2188
2256
|
const orders = await request('perpsGetOpenOrders', []);
|
|
2189
2257
|
if (await guard()) return { changed: true };
|
|
2190
2258
|
return { positions, orders };
|
|
2191
|
-
})()`);
|
|
2259
|
+
})()`), { deadline: page.actionDeadline });
|
|
2192
2260
|
if (book?.changed) {
|
|
2193
2261
|
throw Object.assign(
|
|
2194
2262
|
new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
|
|
@@ -2202,9 +2270,9 @@ async function readBoundBook(page, bound) {
|
|
|
2202
2270
|
}
|
|
2203
2271
|
|
|
2204
2272
|
async function readSizeDecimals(page, symbol) {
|
|
2205
|
-
const markets = await page.evaluate(
|
|
2273
|
+
const markets = await withRateLimitRetry('perpsGetMarkets', () => page.evaluate(
|
|
2206
2274
|
`globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
|
|
2207
|
-
);
|
|
2275
|
+
), { deadline: page.actionDeadline });
|
|
2208
2276
|
const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
|
|
2209
2277
|
return market?.szDecimals ?? null;
|
|
2210
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);
|
|
@@ -22,6 +22,8 @@ import path from 'node:path';
|
|
|
22
22
|
|
|
23
23
|
import { captureActiveRecipeRecordingSnapshot, recipeHarnessPath } from '@farmslot/recipe-cli/harness';
|
|
24
24
|
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, extensionIdPath, walletFixturePath } from '../../harness-exports.mjs';
|
|
25
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
26
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
25
27
|
|
|
26
28
|
// Resolve the Farmslot harness through normal package dependencies by default.
|
|
27
29
|
// Local Farmslot source is only a dev override handled by the harness paths module.
|
|
@@ -843,6 +845,8 @@ export async function withExtensionPage(input, callback) {
|
|
|
843
845
|
const origin = `chrome-extension://${extensionId}`;
|
|
844
846
|
const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
|
|
845
847
|
const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
|
|
848
|
+
// Rate-limit retries on this page's reads stop before the runner's process limit.
|
|
849
|
+
page.actionDeadline = liveAdapterDeadline(input.node);
|
|
846
850
|
const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
|
|
847
851
|
if (recoverVisibleWallet) {
|
|
848
852
|
try {
|
|
@@ -1030,7 +1034,7 @@ export class ExtensionPage extends CdpWebPage {
|
|
|
1030
1034
|
}
|
|
1031
1035
|
|
|
1032
1036
|
async readPositions() {
|
|
1033
|
-
return this.evaluate(`(async () => {
|
|
1037
|
+
return withRateLimitRetry('perpsGetPositions', () => this.evaluate(`(async () => {
|
|
1034
1038
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
1035
1039
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
1036
1040
|
const cached = manager?.positions?.cache;
|
|
@@ -1050,7 +1054,7 @@ export class ExtensionPage extends CdpWebPage {
|
|
|
1050
1054
|
connected: Boolean(manager?.positions?.isConnected),
|
|
1051
1055
|
positions: Array.isArray(cached) ? cached : [],
|
|
1052
1056
|
};
|
|
1053
|
-
})()`);
|
|
1057
|
+
})()`), { deadline: this.actionDeadline });
|
|
1054
1058
|
}
|
|
1055
1059
|
|
|
1056
1060
|
async screenshot(contextOrInput, relPath, metadata = {}) {
|
|
@@ -280,6 +280,8 @@ export async function openPerpsHome(page, timeoutMs) {
|
|
|
280
280
|
await pauseForUi(page);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// The sample is capped at 30 and the header and tabs come first in the DOM, so loading
|
|
284
|
+
// markers are listed first; otherwise a Perps view still loading reads as a wrong route.
|
|
283
285
|
const state = await page.evaluate(`({
|
|
284
286
|
href: location.href,
|
|
285
287
|
title: document.title,
|
|
@@ -291,8 +293,14 @@ export async function openPerpsHome(page, timeoutMs) {
|
|
|
291
293
|
})
|
|
292
294
|
.map((element) => element.getAttribute('data-testid'))
|
|
293
295
|
.filter(Boolean)
|
|
296
|
+
.sort((a, b) => Number(b.endsWith('-loading')) - Number(a.endsWith('-loading')))
|
|
294
297
|
.slice(0, 30),
|
|
295
298
|
})`);
|
|
299
|
+
if (await hasVisibleSelector(page, perpsLoading)) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`extension ui.navigate page=perps: Perps home stayed on its loading view (perps-view-loading visible) within the ${timeoutMs}ms budget; observed ${JSON.stringify(state)}.\nNext: check the Perps stream/provider, or raise timeout_ms; a raw hash route does not wait for Perps readiness.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
296
304
|
throw new Error(
|
|
297
305
|
`extension ui.navigate page=perps could not resolve the Perps home through visible UI; observed ${JSON.stringify(state)}. Capture the current UI with mm-harness call ui.screenshot, then use a verified visible control or raw hash/path.`,
|
|
298
306
|
);
|