@deeeed/metamask-harness 0.86.1 → 0.86.2
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 +15 -0
- package/library/actions/extension/perps/close_visible_position.mjs +9 -5
- package/library/actions/extension/perps/mutation-receipt.mjs +28 -7
- package/library/actions/extension/perps/perps.mjs +81 -10
- package/library/actions/mobile/perps/perps.mjs +3 -17
- package/library/actions/mobile/platform/bridge.mjs +23 -4
- package/library/actions/shared/perps/order-policy.mjs +19 -0
- package/library/manifests/extension.action-manifest.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.86.2 - 2026-10-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Extension `metamask.perps.place_order` through the background Perps request accepts the venue's lot-rounded market fill. The controller sizes a USD order to the market's size step, so $12 of SOL at 109.94 fills 0.11 SOL with $12.09 margin. The observer required the margin within $0.01 of $12 and left every such placement `outcome-unknown`. It now reads the market's size decimals before dispatch and computes the lot the controller fills. The position size must equal the venue-reported filled size (or be within one step of the computed lot when the controller reports none, and never off the step grid), and the margin must be within one size step at the current price plus `identity_rounding_tolerance_base_units`. A reported fill more than one step from the computed lot fails. The lot is stored in the receipt (`facts.marketLot`), so a resume checks against it, not against a lot recomputed at a moved price. A lot whose notional exceeds the trusted operation cap is refused before the receipt. A filled position whose exposure (margin × leverage) exceeds the cap leaves the outcome unknown. Market, side and leverage still match exactly, and mainnet limits are unchanged. With no known size step the exact-cent check stays, and the size must equal a reported fill.
|
|
10
|
+
- Background market placements now record the venue's filled order (`acceptance.source: "background-perpsPlaceOrder"`, with `venueOrderId`, `filledSize` and `averagePrice` from `perpsPlaceOrder`) and the pre-order balance (`facts.balanceBeforeBaseUnits`). Before, the receipt had neither, so `metamask.perps.close_visible_position` refused cleanup with "Position cleanup requires the exact accepted venue order ID", even after a successful placement. Cleanup still proves the position from that order's own fills.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Extension `metamask.perps.close_visible_position cleanup_only=true` now takes the released amount from the position's margin (`marginUsed`, already read from the position detail), not from the close modal text. It records the amount in the receipt as `closeReleaseBaseUnits` with `closeReleaseSource: "position-margin"`. Unrealized PnL is not added, because the available balance (Hyperliquid `withdrawable`) already includes it. The modal's "You'll receive" amount (`marginUsed` minus fees) is now a cross-check, recorded as `closeModalReceiveBaseUnits`. If it differs from the margin by more than the reconciliation tolerance, the action refuses before publishing a receipt or clicking. If the modal amount cannot be read, the action no longer refuses; it reconciles on the margin. The $1 tolerance is unchanged.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Mobile route waits (`navigate` and `waitForRoute` in the action library) now end at the calling action's deadline. Before, once a Perps `start_state` or `teardown_state` ran out of budget, the bridge refused each new `get-route` with `CDP_TIMEOUT`. The waits took that refusal for a target transition and kept polling for up to 30 s more on their own 15 s windows. They could not see a route that did arrive, and failed with "did not start before its action deadline: get-route". On a physical Pixel 6a this showed up as `CDP_TIMEOUT` in clean-market setup and teardown, while one `get-route` took 0.1 to 0.5 s. Both now stop at the deadline. A route that never matched fails with the expected route, the last route read and the limit applied; a recovery cut short by the deadline fails with its last bridge error.
|
|
19
|
+
|
|
5
20
|
## 0.86.1 - 2026-10-09
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -421,10 +421,13 @@ export async function closeVisiblePosition(input) {
|
|
|
421
421
|
if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
|
|
422
422
|
throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
|
|
423
423
|
}
|
|
424
|
-
// Without a placement receipt the baseline is the in-position balance, which excludes the
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
424
|
+
// Without a placement receipt the baseline is the in-position balance, which excludes the position's
|
|
425
|
+
// margin. The close returns marginUsed (PnL is already in the balance); the modal receive cross-checks it.
|
|
426
|
+
const closeReleaseBaseUnits = placement ? null : exactBefore.marginBaseUnits;
|
|
427
|
+
const closeModalReceiveBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
|
|
428
|
+
if (closeModalReceiveBaseUnits != null
|
|
429
|
+
&& BigInt(absoluteDelta(closeModalReceiveBaseUnits, closeReleaseBaseUnits)) > BigInt(balanceToleranceBaseUnits)) {
|
|
430
|
+
throw new Error('Visible close receive amount disagrees with the position margin beyond the reconciliation tolerance.');
|
|
428
431
|
}
|
|
429
432
|
const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
|
|
430
433
|
let submissionObserver = null;
|
|
@@ -440,7 +443,8 @@ export async function closeVisiblePosition(input) {
|
|
|
440
443
|
cleanupOnly,
|
|
441
444
|
fundingBalance,
|
|
442
445
|
tradingContext,
|
|
443
|
-
...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
|
|
446
|
+
...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits, closeReleaseSource: 'position-margin' } : {}),
|
|
447
|
+
...(closeModalReceiveBaseUnits != null ? { closeModalReceiveBaseUnits } : {}),
|
|
444
448
|
...(acceptedFill ? { acceptedFill } : {}),
|
|
445
449
|
...(closeSizing ? { closeSizing } : {}),
|
|
446
450
|
},
|
|
@@ -831,8 +831,27 @@ export async function recordExternalMutationCleanupCandidate(input, receipt, can
|
|
|
831
831
|
});
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
// A market order placed through the background Perps request reports the venue's
|
|
835
|
+
// filled order itself; there is no observed network request to bind.
|
|
836
|
+
export const BACKGROUND_ACCEPTANCE_SOURCE = 'background-perpsPlaceOrder';
|
|
837
|
+
|
|
838
|
+
function validFilledOrder(filledSize, averagePrice) {
|
|
839
|
+
return /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,8})?$/u.test(String(filledSize)) && Number(filledSize) > 0
|
|
840
|
+
&& /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{1,18})?$/u.test(String(averagePrice)) && Number(averagePrice) > 0;
|
|
841
|
+
}
|
|
842
|
+
|
|
834
843
|
async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
835
844
|
const { venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits, filledSize, averagePrice } = acceptance;
|
|
845
|
+
if (acceptance.source === BACKGROUND_ACCEPTANCE_SOURCE) {
|
|
846
|
+
if (receipt.record.identity.operation !== 'perps-market-order' || receipt.record.identity.provider !== 'hyperliquid'
|
|
847
|
+
|| !/^[1-9][0-9]{0,19}$/u.test(String(venueOrderId)) || status !== 'filled' || size !== filledSize
|
|
848
|
+
|| !validFilledOrder(filledSize, averagePrice)) {
|
|
849
|
+
throw recoveryError(receipt, 'background market acceptance has invalid filled order identity');
|
|
850
|
+
}
|
|
851
|
+
return writeSubmissionAcceptance(input, receipt, {
|
|
852
|
+
source: BACKGROUND_ACCEPTANCE_SOURCE, venueOrderId, status, size, filledSize, averagePrice,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
836
855
|
const closing = receipt.record.identity.operation === 'perps-close-position';
|
|
837
856
|
const market = closing || receipt.record.identity.operation === 'perps-market-order';
|
|
838
857
|
const sizing = closing ? receipt.record.facts?.closeSizing
|
|
@@ -849,20 +868,22 @@ async function recordSubmissionAcceptance(input, receipt, acceptance) {
|
|
|
849
868
|
|| !BASE_UNITS.test(String(limitPriceBaseUnits))) {
|
|
850
869
|
throw recoveryError(receipt, 'submission acceptance has invalid order identity');
|
|
851
870
|
}
|
|
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))) {
|
|
871
|
+
if (market && status === 'filled' && (!validFilledOrder(filledSize, averagePrice) || Number(filledSize) > Number(size))) {
|
|
855
872
|
throw recoveryError(receipt, 'market acceptance has invalid filled size or price');
|
|
856
873
|
}
|
|
874
|
+
return writeSubmissionAcceptance(input, receipt, {
|
|
875
|
+
venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
|
|
876
|
+
...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function writeSubmissionAcceptance(input, receipt, acceptance) {
|
|
857
881
|
const destination = await receiptDestination(input);
|
|
858
882
|
const current = await readPublished(destination);
|
|
859
883
|
if (!current || current.sha256 !== receipt.sha256 || current.record?.state !== 'outcome-unknown') {
|
|
860
884
|
throw recoveryError(receipt, 'the receipt changed before submission acceptance was recorded');
|
|
861
885
|
}
|
|
862
|
-
const record = { ...current.record, acceptance
|
|
863
|
-
venueOrderId, status, requestId, serviceWorkerTargetId, asset, size, limitPriceBaseUnits,
|
|
864
|
-
...(market && status === 'filled' ? { filledSize, averagePrice } : {}),
|
|
865
|
-
} };
|
|
886
|
+
const record = { ...current.record, acceptance };
|
|
866
887
|
const staged = await stagedRecord(destination.directory, record);
|
|
867
888
|
try {
|
|
868
889
|
await rename(staged.stagedPath, destination.absolute);
|
|
@@ -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,6 +28,7 @@ import {
|
|
|
27
28
|
assertFlatBeforeRoundTrip,
|
|
28
29
|
assertHouseLimitPosition,
|
|
29
30
|
closeImmediatelyRequested,
|
|
31
|
+
marketOrderLot,
|
|
30
32
|
requireHouseNotional,
|
|
31
33
|
} from '../../shared/perps/order-policy.mjs';
|
|
32
34
|
import {
|
|
@@ -780,8 +782,33 @@ function backgroundPositionObservation(position) {
|
|
|
780
782
|
return { market, side, leverage, size: String(position?.size ?? position?.szi), marginBaseUnits };
|
|
781
783
|
}
|
|
782
784
|
|
|
783
|
-
|
|
785
|
+
// Whole size steps in a size, or null when it is off the step grid.
|
|
786
|
+
function sizeLots(size, sizeDecimals) {
|
|
787
|
+
const scaled = Math.abs(Number(size)) * 10 ** sizeDecimals;
|
|
788
|
+
const lots = Math.round(scaled);
|
|
789
|
+
return Number.isSafeInteger(lots) && lots > 0 && Math.abs(scaled - lots) < 1e-6 ? lots : null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs, acceptance) {
|
|
784
793
|
const deadline = Date.now() + timeoutMs;
|
|
794
|
+
const lot = expected.orderType === 'market' ? expected.marketLot : null;
|
|
795
|
+
// A venue-reported fill fixes the exact size; without one the size may be a step
|
|
796
|
+
// off the lot computed here, since the controller sizes at its own fresh price.
|
|
797
|
+
const acceptedLots = lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
|
|
798
|
+
? sizeLots(acceptance.filledSize, lot.sizeDecimals) : null;
|
|
799
|
+
if (lot && acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE
|
|
800
|
+
&& (acceptedLots == null || Math.abs(acceptedLots - lot.lots) > 1)) {
|
|
801
|
+
throw new Error('Background Perps market fill size is not within one size step of the requested amount.');
|
|
802
|
+
}
|
|
803
|
+
const marginToleranceBaseUnits = lot
|
|
804
|
+
? `${BigInt(expected.roundingToleranceBaseUnits) + BigInt(lot.stepMarginBaseUnits)}`
|
|
805
|
+
: expected.roundingToleranceBaseUnits;
|
|
806
|
+
const reported = acceptance?.source === BACKGROUND_ACCEPTANCE_SOURCE;
|
|
807
|
+
const sizeMatches = (size) => {
|
|
808
|
+
if (!lot) return !reported || Math.abs(Number(size)) === Number(acceptance.filledSize);
|
|
809
|
+
const observed = sizeLots(size, lot.sizeDecimals);
|
|
810
|
+
return observed != null && (acceptedLots != null ? observed === acceptedLots : Math.abs(observed - lot.lots) <= 1);
|
|
811
|
+
};
|
|
785
812
|
while (Date.now() <= deadline) {
|
|
786
813
|
if (expected.orderType === 'limit') {
|
|
787
814
|
const matches = (await readOpenOrders(page))
|
|
@@ -798,12 +825,20 @@ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
|
|
|
798
825
|
&& position.market === expected.market
|
|
799
826
|
&& position.side === expected.side
|
|
800
827
|
&& position.leverage === expected.leverage
|
|
828
|
+
&& sizeMatches(position.size)
|
|
801
829
|
&& withinBaseUnitTolerance(
|
|
802
830
|
position.marginBaseUnits,
|
|
803
831
|
expected.amountBaseUnits,
|
|
804
|
-
|
|
832
|
+
marginToleranceBaseUnits,
|
|
805
833
|
));
|
|
806
|
-
if (matches.length === 1)
|
|
834
|
+
if (matches.length === 1) {
|
|
835
|
+
// The step tolerance widens the identity match, never the exposure the run allowed.
|
|
836
|
+
if (expected.maximumNotionalBaseUnits != null
|
|
837
|
+
&& BigInt(matches[0].marginBaseUnits) * BigInt(matches[0].leverage) > BigInt(expected.maximumNotionalBaseUnits)) {
|
|
838
|
+
throw new Error('Background Perps position exposure exceeds the runner-trusted operation cap.');
|
|
839
|
+
}
|
|
840
|
+
return { success: true, position: matches[0] };
|
|
841
|
+
}
|
|
807
842
|
if (matches.length > 1) throw new Error('Background position postcondition matched more than one position.');
|
|
808
843
|
}
|
|
809
844
|
await sleep(250);
|
|
@@ -870,7 +905,12 @@ async function dispatchBackgroundPlace(page, {
|
|
|
870
905
|
};
|
|
871
906
|
const result = await request('perpsPlaceOrder', [orderParams]);
|
|
872
907
|
if (!result || result.success !== true) throw new Error(result?.error || 'perpsPlaceOrder failed.');
|
|
873
|
-
return {
|
|
908
|
+
return {
|
|
909
|
+
success: true,
|
|
910
|
+
orderId: result.orderId == null ? null : String(result.orderId),
|
|
911
|
+
filledSize: result.filledSize == null ? null : String(result.filledSize),
|
|
912
|
+
averagePrice: result.averagePrice == null ? null : String(result.averagePrice),
|
|
913
|
+
};
|
|
874
914
|
})()`, { awaitPromise: true });
|
|
875
915
|
}
|
|
876
916
|
|
|
@@ -1246,10 +1286,23 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1246
1286
|
}
|
|
1247
1287
|
}
|
|
1248
1288
|
}
|
|
1289
|
+
if (!existing && expected.marketLot
|
|
1290
|
+
&& BigInt(expected.marketLot.notionalBaseUnits) > BigInt(expected.maximumNotionalBaseUnits)) {
|
|
1291
|
+
throw new Error('Rounded Perps market lot exceeds the runner-trusted operation cap.');
|
|
1292
|
+
}
|
|
1293
|
+
// A resume checks the fill against the lot sized at publication, not one
|
|
1294
|
+
// recomputed at a price that has since moved.
|
|
1295
|
+
const marketLot = existing?.record?.facts?.marketLot ?? expected.marketLot ?? null;
|
|
1249
1296
|
const mutation = await runGuardedExternalMutation({
|
|
1250
1297
|
input,
|
|
1251
1298
|
identity,
|
|
1252
|
-
|
|
1299
|
+
// The pre-order balance lets a receipt-bound close reconcile its final balance.
|
|
1300
|
+
publicationFacts: {
|
|
1301
|
+
balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
|
|
1302
|
+
baselineOrderIds: [...baselineIds],
|
|
1303
|
+
fundingBalance,
|
|
1304
|
+
...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
|
|
1305
|
+
},
|
|
1253
1306
|
dispatch: async () => {
|
|
1254
1307
|
try {
|
|
1255
1308
|
await requireCurrentVisibleFundingBalance(
|
|
@@ -1278,13 +1331,26 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1278
1331
|
if (accepted?.changed) {
|
|
1279
1332
|
throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
|
|
1280
1333
|
}
|
|
1334
|
+
// The venue's filled order ID goes into the receipt, so cleanup can prove the
|
|
1335
|
+
// position is this order's own.
|
|
1336
|
+
if (orderType === 'market' && accepted?.orderId != null && accepted.filledSize != null && accepted.averagePrice != null) {
|
|
1337
|
+
return {
|
|
1338
|
+
source: BACKGROUND_ACCEPTANCE_SOURCE,
|
|
1339
|
+
venueOrderId: accepted.orderId,
|
|
1340
|
+
status: 'filled',
|
|
1341
|
+
size: accepted.filledSize,
|
|
1342
|
+
filledSize: accepted.filledSize,
|
|
1343
|
+
averagePrice: accepted.averagePrice,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1281
1346
|
return accepted;
|
|
1282
1347
|
},
|
|
1283
|
-
observe: async () => observeBackgroundPlace(
|
|
1348
|
+
observe: async ({ receipt }) => observeBackgroundPlace(
|
|
1284
1349
|
page,
|
|
1285
|
-
expected,
|
|
1350
|
+
{ ...expected, marketLot },
|
|
1286
1351
|
baselineIds,
|
|
1287
1352
|
Number(input.node?.timeout_ms ?? 30_000),
|
|
1353
|
+
receipt?.acceptance,
|
|
1288
1354
|
),
|
|
1289
1355
|
});
|
|
1290
1356
|
return {
|
|
@@ -1676,8 +1742,9 @@ export async function placeOrder(input) {
|
|
|
1676
1742
|
const slippage = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300) / 10_000;
|
|
1677
1743
|
const margin = Number(amount);
|
|
1678
1744
|
if (szDecimals == null) throw new Error(`close_immediately cannot read the ${symbol} size decimals; not dispatched.`);
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1745
|
+
const lot = marketOrderLot({ amount, leverage, price, szDecimals });
|
|
1746
|
+
const tolerance = lot ? Number(lot.stepMarginBaseUnits) + Math.ceil(margin * slippage * 1_000_000) + 10_000 : Number.NaN;
|
|
1747
|
+
if (!Number.isFinite(tolerance) || tolerance > 1_000_000) {
|
|
1681
1748
|
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
1749
|
}
|
|
1683
1750
|
// The receipt identity must not move with the price, so a resume finds it:
|
|
@@ -1743,15 +1810,18 @@ export async function placeOrder(input) {
|
|
|
1743
1810
|
symbol,
|
|
1744
1811
|
input.node?.current_price ?? input.node?.currentPrice,
|
|
1745
1812
|
);
|
|
1813
|
+
const szDecimals = lane.network === 'mainnet' || orderType === 'market'
|
|
1814
|
+
? await readSizeDecimals(page, symbol) : null;
|
|
1746
1815
|
houseNotional = requireHouseNotional({
|
|
1747
1816
|
network: lane.network,
|
|
1748
1817
|
symbol,
|
|
1749
1818
|
amount,
|
|
1750
1819
|
leverage,
|
|
1751
1820
|
price: resolvedPrice,
|
|
1752
|
-
szDecimals
|
|
1821
|
+
szDecimals,
|
|
1753
1822
|
maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
|
|
1754
1823
|
});
|
|
1824
|
+
const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
|
|
1755
1825
|
const backgroundLimitPrice = orderType === 'limit'
|
|
1756
1826
|
? resolveLimitPrice(input, isBuy, resolvedPrice)
|
|
1757
1827
|
: null;
|
|
@@ -1764,6 +1834,7 @@ export async function placeOrder(input) {
|
|
|
1764
1834
|
expected: {
|
|
1765
1835
|
...expectedVisibleIdentity,
|
|
1766
1836
|
limitPriceBaseUnits: backgroundLimitPrice == null ? null : parseUsdBaseUnits(backgroundLimitPrice),
|
|
1837
|
+
...(marketLot ? { marketLot } : {}),
|
|
1767
1838
|
},
|
|
1768
1839
|
identity,
|
|
1769
1840
|
isBuy,
|
|
@@ -4,7 +4,10 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import {
|
|
5
5
|
MOBILE_BRIDGE_RESULT_ERROR_CODES,
|
|
6
6
|
bridgeCommand,
|
|
7
|
+
deadlineFromTimeout,
|
|
8
|
+
earliestDeadline,
|
|
7
9
|
evalAsync,
|
|
10
|
+
inheritedDeadline,
|
|
8
11
|
isTargetTransition,
|
|
9
12
|
navigate,
|
|
10
13
|
routeSatisfiesNavigationTarget,
|
|
@@ -94,23 +97,6 @@ const MODE_ROOT_TEST_IDS = {
|
|
|
94
97
|
};
|
|
95
98
|
const READ_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
96
99
|
|
|
97
|
-
function inheritedDeadline(input) {
|
|
98
|
-
const deadline = Number(input.node?._action_deadline_epoch_ms);
|
|
99
|
-
return Number.isFinite(deadline) ? deadline : null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function earliestDeadline(input, deadline) {
|
|
103
|
-
const inherited = inheritedDeadline(input);
|
|
104
|
-
return inherited === null ? deadline : Math.min(deadline, inherited);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function deadlineFromTimeout(input, timeoutMs) {
|
|
108
|
-
return earliestDeadline(
|
|
109
|
-
input,
|
|
110
|
-
Date.now() + Math.max(0, Number(timeoutMs)),
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
100
|
async function sleepBeforeDeadline(deadline, maximumMs, wait = sleep) {
|
|
115
101
|
const remainingMs = deadline - Date.now();
|
|
116
102
|
if (remainingMs <= 0) return;
|
|
@@ -663,6 +663,24 @@ function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expec
|
|
|
663
663
|
);
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
export function inheritedDeadline(input) {
|
|
667
|
+
const deadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
|
|
668
|
+
return Number.isFinite(deadline) ? deadline : null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function earliestDeadline(input, deadline) {
|
|
672
|
+
const inherited = inheritedDeadline(input);
|
|
673
|
+
return inherited === null ? deadline : Math.min(deadline, inherited);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// The earlier of now + timeoutMs and the calling action's deadline. Route waits
|
|
677
|
+
// use it because past the action deadline bridgeCommand refuses to start
|
|
678
|
+
// get-route (CDP_TIMEOUT): polling on would only retry that refusal and could
|
|
679
|
+
// never see a route that did arrive.
|
|
680
|
+
export function deadlineFromTimeout(input, timeoutMs) {
|
|
681
|
+
return earliestDeadline(input, Date.now() + Math.max(0, Number(timeoutMs)));
|
|
682
|
+
}
|
|
683
|
+
|
|
666
684
|
async function waitForNextRouteProbe(deadline) {
|
|
667
685
|
const remaining = deadline - Date.now();
|
|
668
686
|
if (remaining > 0) await sleep(Math.min(250, remaining));
|
|
@@ -712,7 +730,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
|
|
|
712
730
|
|
|
713
731
|
while (navigateAttempts < 2 || recoveryPending) {
|
|
714
732
|
if (recoveryPending) {
|
|
715
|
-
const deadline = recoveryDeadline ??
|
|
733
|
+
const deadline = recoveryDeadline ?? deadlineFromTimeout(input, timeoutMs);
|
|
716
734
|
let currentRoute = null;
|
|
717
735
|
while (Date.now() < deadline) {
|
|
718
736
|
try {
|
|
@@ -798,7 +816,7 @@ export async function navigate(input, route, params = {}, expectedRoute) {
|
|
|
798
816
|
if (!isTargetTransition(error)) throw error;
|
|
799
817
|
lastTransitionError = error;
|
|
800
818
|
transitionCodes.push(error.code);
|
|
801
|
-
recoveryDeadline =
|
|
819
|
+
recoveryDeadline = deadlineFromTimeout(input, timeoutMs);
|
|
802
820
|
recoveryPending = true;
|
|
803
821
|
await waitForNextRouteProbe(recoveryDeadline);
|
|
804
822
|
}
|
|
@@ -820,7 +838,8 @@ export async function waitForRoute(
|
|
|
820
838
|
expectedParams,
|
|
821
839
|
) {
|
|
822
840
|
const expected = String(expectedRoute);
|
|
823
|
-
const
|
|
841
|
+
const startedAt = Date.now();
|
|
842
|
+
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
824
843
|
// lastRoute is null when bridgeCommand returns null (transient: route not yet
|
|
825
844
|
// settled mid-navigation). Null means "not ready yet" — keep polling.
|
|
826
845
|
let lastRoute = null;
|
|
@@ -857,7 +876,7 @@ export async function waitForRoute(
|
|
|
857
876
|
? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
|
|
858
877
|
: JSON.stringify(lastRoute);
|
|
859
878
|
const timeoutError = new Error(
|
|
860
|
-
`Timed out waiting for Mobile route '${expected}' after ${
|
|
879
|
+
`Timed out waiting for Mobile route '${expected}' after ${Date.now() - startedAt}ms (${pollCount} polls; limit ${deadline - startedAt}ms of ${timeoutMs}ms requested).\n` +
|
|
861
880
|
` Expected route: ${expected}\n` +
|
|
862
881
|
` Expected params: ${expectedParams === undefined ? 'any' : JSON.stringify(expectedParams)}\n` +
|
|
863
882
|
` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
|
|
@@ -123,6 +123,25 @@ export function worstCaseNotionalUsd({ amount, leverage, price, szDecimals, maxS
|
|
|
123
123
|
return (usd + step * Number(price)) * (1 + Number(maxSlippageBps) / 10_000);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// The lot the controller submits for a market order of `amount` USDC margin (the
|
|
127
|
+
// same rounding as above), in whole size steps, its notional, and the margin one
|
|
128
|
+
// step adds at `price`. Null when the size decimals or price are unknown.
|
|
129
|
+
export function marketOrderLot({ amount, leverage, price, szDecimals }) {
|
|
130
|
+
const decimals = szDecimals == null || szDecimals === '' ? Number.NaN : Number(szDecimals);
|
|
131
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8 || !(Number(price) > 0)) return null;
|
|
132
|
+
const multiplier = 10 ** decimals;
|
|
133
|
+
const usd = Number(amount) * Number(leverage);
|
|
134
|
+
let lots = Math.round((usd / Number(price)) * multiplier);
|
|
135
|
+
if ((lots / multiplier) * Number(price) < usd) lots += 1;
|
|
136
|
+
if (!Number.isSafeInteger(lots) || lots <= 0) return null;
|
|
137
|
+
return {
|
|
138
|
+
sizeDecimals: decimals,
|
|
139
|
+
lots,
|
|
140
|
+
notionalBaseUnits: `${Math.ceil((lots / multiplier) * Number(price) * 1_000_000)}`,
|
|
141
|
+
stepMarginBaseUnits: `${Math.ceil((Number(price) / multiplier / Number(leverage)) * 1_000_000)}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
126
145
|
// Mainnet only: refuse before dispatch when the size the controller would submit
|
|
127
146
|
// could fill above the house notional limit. Testnet needs no bound.
|
|
128
147
|
export function requireHouseNotional({ network, symbol, amount, leverage, price, szDecimals, maxSlippageBps }) {
|
|
@@ -4465,7 +4465,7 @@
|
|
|
4465
4465
|
"identity_rounding_tolerance_base_units": {
|
|
4466
4466
|
"type": "string",
|
|
4467
4467
|
"default": "10000",
|
|
4468
|
-
"description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000."
|
|
4468
|
+
"description": "Maximum visible amount/price rounding delta in six-decimal USDC base units; default 10000 and capped at 1000000. A background market order's margin check adds one venue size step at the current price."
|
|
4469
4469
|
}
|
|
4470
4470
|
},
|
|
4471
4471
|
"additionalProperties": false
|