dexbot 1.6.1 → 1.6.3
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 +34 -0
- package/analysis/README.md +2 -2
- package/analysis/ama_fitting/package.json +1 -1
- package/analysis/grid_correction_check.ts +80 -7
- package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +2455 -0
- package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +218 -0
- package/analysis/tradingview/h-bts_tradingview.html +1570 -0
- package/analysis/tradingview/t-bts_tradingview.html +1570 -0
- package/analysis/tradingview/tradingview_uplot_chart_generator.ts +19 -5
- package/analysis/trend_detection/package.json +1 -1
- package/claw/package.json +1 -1
- package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
- package/claw/runtimes/openclaw-plugin/package.json +1 -1
- package/claw/tests/test_claw_mcp_transport.ts +2 -2
- package/dist/analysis/grid_correction_check.d.ts +5 -1
- package/dist/analysis/grid_correction_check.d.ts.map +1 -1
- package/dist/analysis/grid_correction_check.js +81 -7
- package/dist/analysis/grid_correction_check.js.map +1 -1
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
- package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
- package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
- package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
- package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
- package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
- package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
- package/dist/market_adapter/inputs/window_cache.js +328 -87
- package/dist/market_adapter/inputs/window_cache.js.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.js +18 -4
- package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
- package/dist/modules/bitshares_client.d.ts.map +1 -1
- package/dist/modules/bitshares_client.js +10 -0
- package/dist/modules/bitshares_client.js.map +1 -1
- package/dist/modules/constants.d.ts +7 -3
- package/dist/modules/constants.d.ts.map +1 -1
- package/dist/modules/constants.js +44 -20
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/credit_runtime.d.ts.map +1 -1
- package/dist/modules/credit_runtime.js +24 -6
- package/dist/modules/credit_runtime.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +32 -7
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +55 -11
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +137 -7
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.js +955 -45
- package/dist/modules/dexbot_cow_runtime.js.map +1 -1
- package/dist/modules/dexbot_fill_runtime.d.ts +28 -1
- package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_fill_runtime.js +116 -3
- package/dist/modules/dexbot_fill_runtime.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts +46 -0
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +193 -6
- package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
- package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_startup_runtime.js +6 -1
- package/dist/modules/dexbot_startup_runtime.js.map +1 -1
- package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
- package/dist/modules/dexbot_state_recovery.js +16 -3
- package/dist/modules/dexbot_state_recovery.js.map +1 -1
- package/dist/modules/order/grid.d.ts +3 -1
- package/dist/modules/order/grid.d.ts.map +1 -1
- package/dist/modules/order/grid.js +47 -6
- package/dist/modules/order/grid.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.js +39 -4
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
- package/dist/modules/order/manager.d.ts.map +1 -1
- package/dist/modules/order/manager.js +3 -1
- package/dist/modules/order/manager.js.map +1 -1
- package/dist/modules/order/sync_engine.d.ts.map +1 -1
- package/dist/modules/order/sync_engine.js +200 -15
- package/dist/modules/order/sync_engine.js.map +1 -1
- package/dist/modules/order/utils/math.d.ts +60 -14
- package/dist/modules/order/utils/math.d.ts.map +1 -1
- package/dist/modules/order/utils/math.js +87 -20
- package/dist/modules/order/utils/math.js.map +1 -1
- package/dist/modules/order/utils/order.d.ts +95 -7
- package/dist/modules/order/utils/order.d.ts.map +1 -1
- package/dist/modules/order/utils/order.js +277 -15
- package/dist/modules/order/utils/order.js.map +1 -1
- package/dist/modules/utils/errors.d.ts +22 -0
- package/dist/modules/utils/errors.d.ts.map +1 -1
- package/dist/modules/utils/errors.js +30 -0
- package/dist/modules/utils/errors.js.map +1 -1
- package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
- package/docs/DEXBOT_COMPARISON.md +3 -7
- package/docs/EVOLUTION.md +9 -7
- package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
- package/docs/LIFECYCLE.md +2 -2
- package/docs/README.md +9 -1
- package/docs/architecture.md +7 -7
- package/docs/developer_guide.md +4 -4
- package/market_adapter/README.md +1 -1
- package/package.json +1 -1
|
@@ -76,7 +76,7 @@ import * as FormatModule from './order/format.js';
|
|
|
76
76
|
const Format = FormatModule;
|
|
77
77
|
import * as workingGridModule from './order/working_grid.js';
|
|
78
78
|
const { WorkingGrid } = workingGridModule;
|
|
79
|
-
import { getErrorMessage } from './utils/errors.js';
|
|
79
|
+
import { getErrorMessage, resolveSeamMs, resolveSeamMsOrNull } from './utils/errors.js';
|
|
80
80
|
// Maximum number of times the pre-broadcast staleness guard may re-plan the
|
|
81
81
|
// batch from a fresh master before proceeding anyway. Bounded so a master
|
|
82
82
|
// grid that keeps mutating (fill bursts, sync loops) can never livelock the
|
|
@@ -323,17 +323,23 @@ function formatUnmatchedChainOrderForLog(order) {
|
|
|
323
323
|
* Record a pending CREATE broadcast on the manager.
|
|
324
324
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
325
325
|
* @param {Object} entry
|
|
326
|
+
* @returns {string|null} The fingerprint the entry was stored under (null
|
|
327
|
+
* when recording was skipped). Callers that must later remap the entry's
|
|
328
|
+
* stored opIndex/ctxIndex (the final pivot gate's compaction) collect
|
|
329
|
+
* these to identify exactly which pending entries belong to THIS batch —
|
|
330
|
+
* entry.batchId is unreliable because _currentBatchId is never populated
|
|
331
|
+
* in production (always null), so batchId scoping cannot discriminate.
|
|
326
332
|
*/
|
|
327
333
|
function recordPendingBroadcast(bot, entry) {
|
|
328
334
|
if (!bot.manager || !entry || !entry.order)
|
|
329
|
-
return;
|
|
335
|
+
return null;
|
|
330
336
|
if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
|
|
331
337
|
bot.manager._pendingBroadcasts = new Map();
|
|
332
338
|
}
|
|
333
339
|
const fingerprint = createOpFingerprintForSlot(bot, entry.order, entry.finalInts, entry.order.id);
|
|
334
340
|
if (!fingerprint) {
|
|
335
341
|
bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
|
|
336
|
-
return;
|
|
342
|
+
return null;
|
|
337
343
|
}
|
|
338
344
|
bot.manager._pendingBroadcasts.set(fingerprint, {
|
|
339
345
|
fingerprint,
|
|
@@ -347,6 +353,7 @@ function recordPendingBroadcast(bot, entry) {
|
|
|
347
353
|
batchId: bot._currentBatchId || null,
|
|
348
354
|
recordedAt: Date.now()
|
|
349
355
|
});
|
|
356
|
+
return fingerprint;
|
|
350
357
|
}
|
|
351
358
|
/**
|
|
352
359
|
* Clear the pending-broadcast cache.
|
|
@@ -1286,11 +1293,11 @@ function formatPartialBroadcastSummary(err) {
|
|
|
1286
1293
|
return `${broadcast ?? '?'}/${total ?? '?'} groups broadcast`;
|
|
1287
1294
|
}
|
|
1288
1295
|
/**
|
|
1289
|
-
* Execute a batch with retry-on-uncertain semantics, enforcing a
|
|
1290
|
-
* operation cap (
|
|
1291
|
-
* operations than the cap, it is split into sequential broadcast chunks
|
|
1292
|
-
* most `maxOps` operations each, so a single on-chain transaction never
|
|
1293
|
-
* more than
|
|
1296
|
+
* Execute a batch with retry-on-uncertain semantics, enforcing a gap-slot
|
|
1297
|
+
* per-broadcast operation cap (_getGapSlotBatchSize). When the batch carries
|
|
1298
|
+
* more operations than the cap, it is split into sequential broadcast chunks
|
|
1299
|
+
* of at most `maxOps` operations each, so a single on-chain transaction never
|
|
1300
|
+
* holds more than gapSlots order operations (the original "N fills
|
|
1294
1301
|
* per broadcast" intent, applied at the op level rather than the fill level).
|
|
1295
1302
|
*
|
|
1296
1303
|
* Failure isolation — no swallowed orders: if one chunk's broadcast is
|
|
@@ -1316,12 +1323,11 @@ function formatPartialBroadcastSummary(err) {
|
|
|
1316
1323
|
* @returns {Promise<{result: Object, opContexts: Array}>}
|
|
1317
1324
|
*/
|
|
1318
1325
|
async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
|
|
1319
|
-
const
|
|
1326
|
+
const fromAccessor = typeof bot._getMaxOpsPerBroadcast === 'function'
|
|
1320
1327
|
? bot._getMaxOpsPerBroadcast()
|
|
1321
|
-
:
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
: 1;
|
|
1328
|
+
: (typeof bot._getGapSlotBatchSize === 'function' ? bot._getGapSlotBatchSize() : undefined);
|
|
1329
|
+
const requested = Number(fromAccessor);
|
|
1330
|
+
const maxOps = Number.isFinite(requested) && requested >= 1 ? Math.floor(requested) : 1;
|
|
1325
1331
|
if (!Array.isArray(operations) || operations.length <= maxOps) {
|
|
1326
1332
|
return await executeWithRetryOnUncertain(bot, operations, opContexts);
|
|
1327
1333
|
}
|
|
@@ -1332,7 +1338,7 @@ async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
|
|
|
1332
1338
|
opContexts: opContexts.slice(i, i + maxOps),
|
|
1333
1339
|
});
|
|
1334
1340
|
}
|
|
1335
|
-
bot.manager.logger.log(`[COW] Splitting ${operations.length} operations into ${chunks.length} broadcast chunk(s) of at most ${maxOps} ops each (
|
|
1341
|
+
bot.manager.logger.log(`[COW] Splitting ${operations.length} operations into ${chunks.length} broadcast chunk(s) of at most ${maxOps} ops each (gap-slot batch size).`, 'info');
|
|
1336
1342
|
const mergedOperationResults = [];
|
|
1337
1343
|
const mergedRawResults = [];
|
|
1338
1344
|
const mergedContexts = [];
|
|
@@ -1609,6 +1615,192 @@ function resolveIdealSizeForValidation(_bot, orderLike, fallbackSize = null) {
|
|
|
1609
1615
|
function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
|
|
1610
1616
|
return validateOrderSize(size, type, bot.manager.assets, bot.config.gridLimits?.MIN_ORDER_SIZE_FACTOR, resolveIdealSizeForValidation(bot, orderLike, fallbackSize), bot.config.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
|
|
1611
1617
|
}
|
|
1618
|
+
function checkGridPriceInvariant(slotId, price, genesis) {
|
|
1619
|
+
return orderUtils.checkGridPriceInvariant(slotId, price, genesis);
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Derive the authoritative price for a rotation destination slot.
|
|
1623
|
+
*
|
|
1624
|
+
* A rotation re-prices to the destination slot, so the destination's genesis
|
|
1625
|
+
* level is the authoritative price — not `action.newPrice`, which the planner
|
|
1626
|
+
* copies from the destination hole's `order.price` and which is therefore only
|
|
1627
|
+
* as sound as whatever last wrote that object. Deriving the emitted price here
|
|
1628
|
+
* means a planner bug cannot produce a mis-priced UPDATE on its own; the
|
|
1629
|
+
* invariant check remains as the backstop for the no-genesis case.
|
|
1630
|
+
*
|
|
1631
|
+
* @returns {number} the destination's genesis level, or NaN when there is no
|
|
1632
|
+
* genesis ladder / no parseable destination index (caller falls back to the
|
|
1633
|
+
* planned price, which is the migration case the checker fails open on).
|
|
1634
|
+
*/
|
|
1635
|
+
function deriveRotationPrice(bot, newGridId) {
|
|
1636
|
+
try {
|
|
1637
|
+
const idx = parseSlotIndex(newGridId);
|
|
1638
|
+
if (idx === null || idx === undefined || !Number.isFinite(idx))
|
|
1639
|
+
return NaN;
|
|
1640
|
+
const genesis = bot?.manager?._genesis;
|
|
1641
|
+
if (!Array.isArray(genesis?.priceLevels) || genesis.priceLevels.length === 0)
|
|
1642
|
+
return NaN;
|
|
1643
|
+
const lvl = Number(math.priceForSlot(idx, genesis));
|
|
1644
|
+
return (Number.isFinite(lvl) && lvl > 0) ? lvl : NaN;
|
|
1645
|
+
}
|
|
1646
|
+
catch {
|
|
1647
|
+
return NaN;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Record one GRID-PRICE-INVARIANT check for an emitted order price.
|
|
1652
|
+
*
|
|
1653
|
+
* BLOCKING: returns false when the price is a genuine off-grid mismatch, so the
|
|
1654
|
+
* caller must SKIP the emission. A price that is not a slot's genesis level is
|
|
1655
|
+
* not a valid grid price, so placing it is the failure this guard exists to
|
|
1656
|
+
* prevent — rejecting is the point, not a side effect.
|
|
1657
|
+
*
|
|
1658
|
+
* Fails OPEN on anything unjudgeable (no genesis, unparseable id, non-finite
|
|
1659
|
+
* price, checker error): those are metadata problems, not off-grid prices, and
|
|
1660
|
+
* blocking on them would halt legitimate trading. Only 'off-grid-price' blocks.
|
|
1661
|
+
*
|
|
1662
|
+
* A missing slotId is counted as unchecked rather than falling back to another
|
|
1663
|
+
* id: for a rotation UPDATE the id must be the DESTINATION slot (newPrice is
|
|
1664
|
+
* that slot's price), so substituting the source would flag every legitimate
|
|
1665
|
+
* relocation. Callers pass the id they actually mean, or nothing.
|
|
1666
|
+
*
|
|
1667
|
+
* @returns {boolean} true when the caller MAY emit; false when it must skip
|
|
1668
|
+
*/
|
|
1669
|
+
function recordGridPriceInvariantCheck(bot, slotId, price, stats, site) {
|
|
1670
|
+
try {
|
|
1671
|
+
if (slotId == null || slotId === '') {
|
|
1672
|
+
stats.unchecked++;
|
|
1673
|
+
return true;
|
|
1674
|
+
}
|
|
1675
|
+
const inv = checkGridPriceInvariant(slotId, price, bot?.manager?._genesis);
|
|
1676
|
+
if (inv.reason !== 'ok' && inv.reason !== 'off-grid-price') {
|
|
1677
|
+
stats.unchecked++;
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
stats.checked++;
|
|
1681
|
+
if (inv.ok) {
|
|
1682
|
+
// A CLEAN check clears the run for this slot: escalation must mean
|
|
1683
|
+
// "rejected N consecutive batches", not "rejected N times ever".
|
|
1684
|
+
// Without this reset a slot rejected once an hour would accumulate
|
|
1685
|
+
// to the threshold over a day and fire a resync it never earned.
|
|
1686
|
+
const streakMap = getInvariantRejectStreak(bot);
|
|
1687
|
+
if (streakMap.has(String(slotId)))
|
|
1688
|
+
streakMap.delete(String(slotId));
|
|
1689
|
+
return true;
|
|
1690
|
+
}
|
|
1691
|
+
stats.violated++;
|
|
1692
|
+
orderUtils.reportGridPriceInvariant(bot?.manager, slotId, price, site);
|
|
1693
|
+
considerGridPriceInvariantEscalation(bot, slotId, price, inv, site);
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
catch {
|
|
1697
|
+
// A checker failure must never block a broadcast.
|
|
1698
|
+
return true;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Per-slot count of CONSECUTIVE batches that rejected this slot's emission as
|
|
1703
|
+
* off-grid, scoped to the BOT rather than the module.
|
|
1704
|
+
*
|
|
1705
|
+
* Scope matters because the monolithic runtime (`dexbot.ts`, the `dexbot` bin)
|
|
1706
|
+
* constructs EVERY active bot in one process, so a module-level map would pool
|
|
1707
|
+
* unrelated bots' rejections: one bot rejecting a slot twice would leave the
|
|
1708
|
+
* next bot at the threshold on its FIRST rejection and fire a spurious
|
|
1709
|
+
* structural resync (reload, possibly a full grid reset) on a healthy bot.
|
|
1710
|
+
* Keeping it on the bot also keeps the count from outliving the resync that
|
|
1711
|
+
* repairs the slot, and lets tests start from a clean slate.
|
|
1712
|
+
*
|
|
1713
|
+
* @param {any} bot
|
|
1714
|
+
* @returns {Map<string, number>}
|
|
1715
|
+
*/
|
|
1716
|
+
function getInvariantRejectStreak(bot) {
|
|
1717
|
+
if (!(bot?._gridPriceInvariantRejectStreak instanceof Map)) {
|
|
1718
|
+
bot._gridPriceInvariantRejectStreak = new Map();
|
|
1719
|
+
}
|
|
1720
|
+
return bot._gridPriceInvariantRejectStreak;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Escalate a PERSISTENT off-grid rejection to a structural resync.
|
|
1724
|
+
*
|
|
1725
|
+
* A single rejection is handled correctly by skipping the emission and warning;
|
|
1726
|
+
* the next cycle re-plans. But if the corruption lives in-process (the planner
|
|
1727
|
+
* carries `candidate.price` straight from `manager.orders`), the next cycle
|
|
1728
|
+
* re-plans from the SAME bad `slot.price`, is rejected identically, and warns
|
|
1729
|
+
* again -- forever. The slot is dead while the bot looks healthy.
|
|
1730
|
+
*
|
|
1731
|
+
* After `GRID_PRICE_INVARIANT_RESYNC_THRESHOLD` consecutive rejecting batches
|
|
1732
|
+
* for one slot, ask for the structural resync that repairs it (loadGrid derives
|
|
1733
|
+
* slot prices from the genesis ladder). Fire-and-forget: the resync is already
|
|
1734
|
+
* debounced (`_structuralGridResyncRunning`/`Timer`) and batch-in-flight aware,
|
|
1735
|
+
* so repeats inside the cooldown are cheap and safe.
|
|
1736
|
+
*
|
|
1737
|
+
* @returns {number} the slot's current consecutive-rejection streak
|
|
1738
|
+
*/
|
|
1739
|
+
function considerGridPriceInvariantEscalation(bot, slotId, price, inv, site) {
|
|
1740
|
+
const key = String(slotId);
|
|
1741
|
+
const streakMap = getInvariantRejectStreak(bot);
|
|
1742
|
+
const streak = (streakMap.get(key) || 0) + 1;
|
|
1743
|
+
streakMap.set(key, streak);
|
|
1744
|
+
const threshold = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD) > 0
|
|
1745
|
+
? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD)
|
|
1746
|
+
: 3;
|
|
1747
|
+
if (streak < threshold)
|
|
1748
|
+
return streak;
|
|
1749
|
+
if (typeof bot?.manager?.requestStructuralGridResync !== 'function') {
|
|
1750
|
+
bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} rejected ${streak}x consecutively but ` +
|
|
1751
|
+
`requestStructuralGridResync is unavailable; slot stays unhealed until restart`, 'error');
|
|
1752
|
+
return streak;
|
|
1753
|
+
}
|
|
1754
|
+
// Dedicated cooldown key (NOT BOUNDARY_HOLD_RESYNC_COOLDOWN_MS: the watchdogs
|
|
1755
|
+
// must tune independently). Once per cooldown window is enough -- the streak
|
|
1756
|
+
// keeps counting so a later window escalates again if still unhealed.
|
|
1757
|
+
const cooldownMs = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS) > 0
|
|
1758
|
+
? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS)
|
|
1759
|
+
: 15 * 60 * 1000;
|
|
1760
|
+
const now = Date.now();
|
|
1761
|
+
const lastAt = Number(bot?._lastGridPriceInvariantResyncAt) || 0;
|
|
1762
|
+
if (now - lastAt < cooldownMs)
|
|
1763
|
+
return streak;
|
|
1764
|
+
bot._lastGridPriceInvariantResyncAt = now;
|
|
1765
|
+
const expected = inv?.expected != null ? Number(inv.expected) : NaN;
|
|
1766
|
+
const actual = Number(price);
|
|
1767
|
+
bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} (${site}) rejected ${streak} consecutive batch(es) at ` +
|
|
1768
|
+
`${Number.isFinite(actual) ? Format.formatPrice6(actual) : 'n/a'} ` +
|
|
1769
|
+
`(genesis ${Number.isFinite(expected) ? Format.formatPrice6(expected) : 'n/a'}); ` +
|
|
1770
|
+
`requesting structural resync to repair the slot`, 'error');
|
|
1771
|
+
try {
|
|
1772
|
+
const res = bot.manager.requestStructuralGridResync('grid-price-invariant-violation', {
|
|
1773
|
+
slotId: String(slotId),
|
|
1774
|
+
expected: Number.isFinite(expected) ? expected : null,
|
|
1775
|
+
actual: Number.isFinite(actual) ? actual : null,
|
|
1776
|
+
site,
|
|
1777
|
+
streak,
|
|
1778
|
+
});
|
|
1779
|
+
res?.catch?.((err) => {
|
|
1780
|
+
bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
catch (err) {
|
|
1784
|
+
bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
|
|
1785
|
+
}
|
|
1786
|
+
return streak;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Emit the per-batch GRID-PRICE-INVARIANT summary. Quiet when the batch had no
|
|
1790
|
+
* checkable emission; warn when any off-grid emission was REJECTED, else info.
|
|
1791
|
+
* A rejected emission is logged per-site by reportGridPriceInvariant; this is
|
|
1792
|
+
* the aggregate so a batch that silently placed fewer orders than planned is
|
|
1793
|
+
* explained without reading every line.
|
|
1794
|
+
*/
|
|
1795
|
+
function logGridPriceInvariantSummary(bot, stats, site) {
|
|
1796
|
+
try {
|
|
1797
|
+
if (!stats || stats.checked === 0)
|
|
1798
|
+
return;
|
|
1799
|
+
bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] site=${site} checked=${stats.checked} ` +
|
|
1800
|
+
`violated=${stats.violated} unchecked=${stats.unchecked}`, stats.violated > 0 ? 'warn' : 'info');
|
|
1801
|
+
}
|
|
1802
|
+
catch { /* summary is best-effort */ }
|
|
1803
|
+
}
|
|
1612
1804
|
/**
|
|
1613
1805
|
* LAST-FILL-GUARD helper — pivot ± halfIncrement (replaces price-tolerance).
|
|
1614
1806
|
* last fill @x with increment i: BUY < x*(1 - i/2/100), SELL > x*(1 + i/2/100)
|
|
@@ -1756,6 +1948,14 @@ function refreshLastFillPivotFromQueue(bot) {
|
|
|
1756
1948
|
* Resolve the grid increment percent for the LAST-FILL guard in one place so
|
|
1757
1949
|
* every check site and the batch summary use (and print) the same value.
|
|
1758
1950
|
* Falls back to the default 0.5 when unset/invalid.
|
|
1951
|
+
*
|
|
1952
|
+
* CALL CONTRACT: this takes the BOT (`{ manager }`), not the increment or the
|
|
1953
|
+
* manager. The lookup order below is `bot.manager.config` first, which works
|
|
1954
|
+
* only because `OrderManager` exposes its own `config`. Callers that pass a
|
|
1955
|
+
* bare manager instead of a bot get the `bot.config` / DEFAULT_CONFIG
|
|
1956
|
+
* fallbacks and silently lose the manager's tuning, so pass the bot.
|
|
1957
|
+
* (The manager-first order is deliberate: the guard must use the same
|
|
1958
|
+
* increment the grid was built with, not whatever the bot-level config holds.)
|
|
1759
1959
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
1760
1960
|
* @returns {number} Positive increment percent
|
|
1761
1961
|
*/
|
|
@@ -2074,11 +2274,20 @@ function applyRotationTransitionsToWorkingGrid(bot, workingGrid, executedContext
|
|
|
2074
2274
|
* CANCELs modify existing orders and cannot be reliably distinguished from
|
|
2075
2275
|
* "not yet visible" state by simple polling.
|
|
2076
2276
|
*
|
|
2277
|
+
* @param {Object} [options]
|
|
2278
|
+
* @param {number} [options.maxPollRetries] - Poll attempts before reconciliation fallback
|
|
2279
|
+
* @param {number} [options.pollIntervalMs] - Delay between polls; tests pass 0
|
|
2280
|
+
* to skip the production 1.5s pacing without changing the poll semantics
|
|
2077
2281
|
* @returns {{ allConfirmed: boolean, confirmed: Array, unconfirmed: Array }}
|
|
2078
2282
|
*/
|
|
2079
2283
|
async function pollChainForConfirmation(bot, opContexts, options = {}) {
|
|
2080
2284
|
const maxPollRetries = options.maxPollRetries || 4;
|
|
2081
|
-
|
|
2285
|
+
// Test seam (see updateOrdersOnChainBatchCOW): bot._testPollIntervalMs
|
|
2286
|
+
// overrides the production pacing when set by the tests. An explicit 0 is
|
|
2287
|
+
// honored; null/undefined falls through to the production 1.5s default.
|
|
2288
|
+
const explicitPollMs = options.pollIntervalMs;
|
|
2289
|
+
const seamMs = bot?._testPollIntervalMs;
|
|
2290
|
+
const pollIntervalMs = resolveSeamMs(explicitPollMs, resolveSeamMs(seamMs, 1500));
|
|
2082
2291
|
// Only CREATE operations can be confirmed by polling (they appear as new orders on chain)
|
|
2083
2292
|
const createContexts = opContexts.filter((ctx) => ctx && ctx.kind === 'create' && ctx.finalInts && ctx.order);
|
|
2084
2293
|
if (createContexts.length === 0) {
|
|
@@ -2300,7 +2509,7 @@ function restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedSlotIds
|
|
|
2300
2509
|
* skipped as already-consistent); handled=false when the caller must proceed
|
|
2301
2510
|
* with the original plan.
|
|
2302
2511
|
*/
|
|
2303
|
-
async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard) {
|
|
2512
|
+
async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs) {
|
|
2304
2513
|
const canReplan = replanDepth < STALE_PLAN_REPLAN_LIMIT
|
|
2305
2514
|
&& Array.isArray(cowResult.fills) && cowResult.fills.length > 0;
|
|
2306
2515
|
if (!canReplan) {
|
|
@@ -2356,7 +2565,11 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
|
|
|
2356
2565
|
return {
|
|
2357
2566
|
handled: true,
|
|
2358
2567
|
result: await updateOrdersOnChainBatchCOW(bot, replanned, {
|
|
2359
|
-
replanDepth: replanDepth + 1
|
|
2568
|
+
replanDepth: replanDepth + 1,
|
|
2569
|
+
// Carry the seam through the recursion explicitly: the inner
|
|
2570
|
+
// frame re-sets and re-restores it, so it must not depend on
|
|
2571
|
+
// the outer frame's side-channel value surviving.
|
|
2572
|
+
...(seamPollIntervalMs != null ? { pollIntervalMs: seamPollIntervalMs } : {})
|
|
2360
2573
|
}),
|
|
2361
2574
|
};
|
|
2362
2575
|
}
|
|
@@ -2710,8 +2923,58 @@ async function runPreBroadcastGuards(bot, cowResult) {
|
|
|
2710
2923
|
}
|
|
2711
2924
|
return { proceed: true, crossingCandidates, intraBatchCandidates };
|
|
2712
2925
|
}
|
|
2926
|
+
function restoreTestPollIntervalSeam(bot, prev) {
|
|
2927
|
+
try {
|
|
2928
|
+
if (prev === undefined)
|
|
2929
|
+
delete bot._testPollIntervalMs;
|
|
2930
|
+
else
|
|
2931
|
+
bot._testPollIntervalMs = prev;
|
|
2932
|
+
}
|
|
2933
|
+
catch { /* seam restore must never break the batch */ }
|
|
2934
|
+
}
|
|
2713
2935
|
async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
2714
2936
|
const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
|
|
2937
|
+
// Test seam: options.pollIntervalMs overrides the production 1.5s pacing
|
|
2938
|
+
// in pollChainForConfirmation (missing-create path below) so tests do
|
|
2939
|
+
// not sleep on wall-clock time. Held on the bot only for the duration of
|
|
2940
|
+
// this call (see the wrapper's finally) so the inner missing-create
|
|
2941
|
+
// branch and any re-plan recursion pick it up without changing the
|
|
2942
|
+
// production call signature used by the runtime.
|
|
2943
|
+
const seamPollIntervalMs = resolveSeamMsOrNull(options?.pollIntervalMs);
|
|
2944
|
+
const prevSeamPollIntervalMs = bot?._testPollIntervalMs;
|
|
2945
|
+
if (seamPollIntervalMs != null) {
|
|
2946
|
+
bot._testPollIntervalMs = seamPollIntervalMs;
|
|
2947
|
+
}
|
|
2948
|
+
// Expose the resolved interval using the same precedence the missing-create
|
|
2949
|
+
// poll path applies (explicit option > bot seam > production 1500ms).
|
|
2950
|
+
// Recorded in the wrapper rather than in pollChainForConfirmation so it is
|
|
2951
|
+
// observable on every batch exit, including the pre-broadcast guard
|
|
2952
|
+
// refusals that never reach the poll (a `||`-vs-`??` regression here is
|
|
2953
|
+
// otherwise invisible: the only effect is a slower poll).
|
|
2954
|
+
bot._lastResolvedPollIntervalMs = resolveSeamMs(seamPollIntervalMs, resolveSeamMs(bot?._testPollIntervalMs, 1500));
|
|
2955
|
+
// The seam override must not survive this call: every exit path (dry run,
|
|
2956
|
+
// entry/pre-broadcast single-flight aborts, guard refusals, re-plan
|
|
2957
|
+
// recursion, throws) funnels through the body() finally below, so no exit
|
|
2958
|
+
// can leak bot._testPollIntervalMs onto the bot.
|
|
2959
|
+
try {
|
|
2960
|
+
return await updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs ?? undefined);
|
|
2961
|
+
}
|
|
2962
|
+
finally {
|
|
2963
|
+
// Restore only when this frame actually wrote a seam. The entry
|
|
2964
|
+
// single-flight await (inside body()) lets a second, seam-less batch
|
|
2965
|
+
// run concurrently: an unguarded restore would delete the seam owned
|
|
2966
|
+
// by an in-flight sibling call (captured prev === undefined), and that
|
|
2967
|
+
// sibling's pollChainForConfirmation — which reads the bot field only,
|
|
2968
|
+
// since it takes no pollIntervalMs option at its call sites — would
|
|
2969
|
+
// silently fall back to the production 1500ms pacing. Guarding keeps
|
|
2970
|
+
// the write-ownership scoped to this call; the saved prev still makes
|
|
2971
|
+
// the nested re-plan recursion LIFO-correct.
|
|
2972
|
+
if (seamPollIntervalMs != null) {
|
|
2973
|
+
restoreTestPollIntervalSeam(bot, prevSeamPollIntervalMs);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs) {
|
|
2715
2978
|
bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
|
|
2716
2979
|
const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
|
|
2717
2980
|
// Boundary-hold value: computed pre-broadcast after the skip-restore and
|
|
@@ -2772,7 +3035,12 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2772
3035
|
bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
|
|
2773
3036
|
const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
|
|
2774
3037
|
if (drainResult?.failed > 0) {
|
|
2775
|
-
bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch
|
|
3038
|
+
bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch` +
|
|
3039
|
+
(drainResult.staleDropped > 0 ? `, ${drainResult.staleDropped} stale dropped` : '') +
|
|
3040
|
+
`; remaining entries retry on next sync/maintenance tick`, 'warn');
|
|
3041
|
+
}
|
|
3042
|
+
else if (drainResult?.staleDropped > 0) {
|
|
3043
|
+
bot.manager.logger.log(`[COW] Pre-batch drain resolved, ${drainResult.staleDropped} stale correction(s) dropped`, 'info');
|
|
2776
3044
|
}
|
|
2777
3045
|
}
|
|
2778
3046
|
catch (drainErr) {
|
|
@@ -2798,7 +3066,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2798
3066
|
// Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
|
|
2799
3067
|
// would spam big batches, so the guard emits one batch summary instead
|
|
2800
3068
|
// (see the summary after the action loop below).
|
|
2801
|
-
const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0 };
|
|
3069
|
+
const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0, pivotOffGrid: 0 };
|
|
3070
|
+
// Fingerprints of the pending-broadcast entries recorded by THIS batch's
|
|
3071
|
+
// op-building (both CREATE paths). The final pivot gate's compaction
|
|
3072
|
+
// remaps these entries' stored opIndex/ctxIndex to their post-drop
|
|
3073
|
+
// positions; entry.batchId cannot discriminate batches (always null in
|
|
3074
|
+
// production), so the fingerprint set is the ownership marker.
|
|
3075
|
+
const batchPendingFps = new Set();
|
|
3076
|
+
// Per-batch GRID-PRICE-INVARIANT counters (BLOCKING). Tracks emitted prices
|
|
3077
|
+
// that are not the genesis level for their slot — such emissions are
|
|
3078
|
+
// rejected, not placed. One batch summary.
|
|
3079
|
+
const gridPriceInvariantStats = { checked: 0, violated: 0, unchecked: 0 };
|
|
2802
3080
|
// Whether any guard check in this batch refreshed the pivot from
|
|
2803
3081
|
// still-queued fills — reported in the batch summary so a pivot change
|
|
2804
3082
|
// that altered a guard decision is visible at info, not just debug.
|
|
@@ -2810,7 +3088,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2810
3088
|
const clampedUpdateSlotIds = new Set();
|
|
2811
3089
|
// orderId -> operations index of its cancel op. A crossing re-pricing
|
|
2812
3090
|
// update is only safe when the crossed order's cancel was already queued
|
|
2813
|
-
// at an earlier position: ops broadcast in
|
|
3091
|
+
// at an earlier position: ops broadcast in gap-slot-sized chunks,
|
|
2814
3092
|
// so an earlier index means the cancel confirms on chain (same or earlier
|
|
2815
3093
|
// chunk, applied sequentially) before the crossing order lands.
|
|
2816
3094
|
const cancelOpIndexByOrderId = new Map();
|
|
@@ -2848,12 +3126,33 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2848
3126
|
// mutated the pivot mid-batch (02:03 pivots drifted 0.001523→0.001529
|
|
2849
3127
|
// across 20 checks), so early actions were judged against a different
|
|
2850
3128
|
// pivot than later ones. The batch summary still reports whether this
|
|
2851
|
-
// freeze moved the pivot under the plan.
|
|
3129
|
+
// freeze moved the pivot under the plan. The frozen value feeds the
|
|
3130
|
+
// final pre-broadcast gate below (runFinalPivotGate), which re-checks
|
|
3131
|
+
// built ops when a fill queued AFTER the freeze moved the pivot.
|
|
3132
|
+
// freezeQueueDepth is the Step-2 observability half: queue depth at
|
|
3133
|
+
// freeze time, paired with the gate's own queue readout.
|
|
3134
|
+
let freezeQueueDepth = null;
|
|
3135
|
+
try {
|
|
3136
|
+
freezeQueueDepth = Array.isArray(bot?._incomingFillQueue)
|
|
3137
|
+
? bot._incomingFillQueue.length
|
|
3138
|
+
: null;
|
|
3139
|
+
}
|
|
3140
|
+
catch {
|
|
3141
|
+
freezeQueueDepth = null;
|
|
3142
|
+
}
|
|
2852
3143
|
try {
|
|
2853
3144
|
if (refreshLastFillPivotFromQueue(bot))
|
|
2854
3145
|
lastFillGuardPivotRefreshed = true;
|
|
2855
3146
|
}
|
|
2856
3147
|
catch { /* best-effort */ }
|
|
3148
|
+
// Captured AFTER the freeze refresh, not before: the refresh is part
|
|
3149
|
+
// of the freeze, so the baseline must be the pivot the ops are about
|
|
3150
|
+
// to be judged against. Capturing pre-refresh would make every batch
|
|
3151
|
+
// whose freeze picked up a pre-freeze queued fill look "moved" at the
|
|
3152
|
+
// gate — a spurious warn plus a redundant full re-check against the
|
|
3153
|
+
// identical pivot.
|
|
3154
|
+
const frozenPivotAtBatchStart = bot.manager?._lastFilledPrice;
|
|
3155
|
+
const frozenTypeAtBatchStart = bot.manager?._lastFilledType;
|
|
2857
3156
|
for (const action of actions) {
|
|
2858
3157
|
if (action.type === COW_ACTIONS.CANCEL) {
|
|
2859
3158
|
try {
|
|
@@ -2890,13 +3189,21 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2890
3189
|
const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
|
|
2891
3190
|
? Math.abs(livePrice - plannedPrice)
|
|
2892
3191
|
: 0;
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3192
|
+
// The planned price is emitted as-is. A pre-broadcast
|
|
3193
|
+
// substitution with liveSlot.price used to rewrite it here
|
|
3194
|
+
// at `debug` level, which meant a slot whose price had been
|
|
3195
|
+
// mutated off its genesis level got re-broadcast under a
|
|
3196
|
+
// different number than the plan validated (crossing,
|
|
3197
|
+
// collision and last-fill guards all ran on `createPrice`).
|
|
3198
|
+
// slot.price is derived from the genesis ladder, not
|
|
3199
|
+
// authoritative, so a divergence is a signal to report —
|
|
3200
|
+
// never a value to adopt. Reported at `warn`: a divergence
|
|
3201
|
+
// here is a writer bug, not routine freshness.
|
|
3202
|
+
const effectiveOrder = order;
|
|
2896
3203
|
if (priceDrift > 0) {
|
|
2897
|
-
bot.manager.logger.log(`[COW] Pre-broadcast price
|
|
2898
|
-
`
|
|
2899
|
-
`(
|
|
3204
|
+
bot.manager.logger.log(`[COW] Pre-broadcast price drift on slot ${order.id}: ` +
|
|
3205
|
+
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
|
|
3206
|
+
`emitting planned price (live slot price is derived from genesis and is not authoritative).`, 'warn');
|
|
2900
3207
|
}
|
|
2901
3208
|
const createPrice = effectiveOrder.price;
|
|
2902
3209
|
const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
|
|
@@ -2912,13 +3219,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2912
3219
|
}
|
|
2913
3220
|
// CROSSING-PLACEMENT GUARD (create variant): the batch-level
|
|
2914
3221
|
// validators (validateCreateTargetSlots, detectCrossedBookPlan)
|
|
2915
|
-
// simulated the PLANNED price
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
2919
|
-
//
|
|
2920
|
-
//
|
|
2921
|
-
// self-trade (production
|
|
3222
|
+
// simulated the PLANNED price. That is now the price emitted
|
|
3223
|
+
// (the pre-broadcast substitution was removed), but the guard
|
|
3224
|
+
// is retained as defence-in-depth: it re-checks on the FINAL
|
|
3225
|
+
// price against live and chain-side orders not already
|
|
3226
|
+
// cancelled at an earlier op position — an opposite-side
|
|
3227
|
+
// order cancelled in a later chunk would otherwise coexist
|
|
3228
|
+
// with this create mid-broadcast and self-trade (production
|
|
3229
|
+
// incident class).
|
|
2922
3230
|
const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
|
|
2923
3231
|
const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
|
|
2924
3232
|
const effectiveCrossed = createCrossed || intraBatchCrossed;
|
|
@@ -2959,6 +3267,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2959
3267
|
}
|
|
2960
3268
|
catch (_e) { /* guard is best-effort */ }
|
|
2961
3269
|
const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
|
|
3270
|
+
// GRID-PRICE-INVARIANT (blocking): the CREATE price must be
|
|
3271
|
+
// the genesis level for this slot. A mismatch means state
|
|
3272
|
+
// corruption upstream, so the emission is skipped rather
|
|
3273
|
+
// than placed — the next reconcile cycle re-plans. See
|
|
3274
|
+
// docs/GRID_PRICE_INVARIANT.md.
|
|
3275
|
+
if (!recordGridPriceInvariantCheck(bot, order.id, createPrice, gridPriceInvariantStats, 'CREATE')) {
|
|
3276
|
+
if (order.id)
|
|
3277
|
+
skippedCreateSlotIds.add(order.id);
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
2962
3280
|
const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
|
|
2963
3281
|
if (!buildResult) {
|
|
2964
3282
|
bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
|
|
@@ -2969,12 +3287,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2969
3287
|
operations.push(buildResult.op);
|
|
2970
3288
|
opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
|
|
2971
3289
|
intraBatchCandidates.push(effectiveOrder);
|
|
2972
|
-
recordPendingBroadcast(bot, {
|
|
3290
|
+
const recordedFp = recordPendingBroadcast(bot, {
|
|
2973
3291
|
opIndex: operations.length - 1,
|
|
2974
3292
|
ctxIndex: opContexts.length - 1,
|
|
2975
3293
|
order: effectiveOrder,
|
|
2976
3294
|
finalInts: buildResult.finalInts
|
|
2977
3295
|
});
|
|
3296
|
+
if (recordedFp)
|
|
3297
|
+
batchPendingFps.add(recordedFp);
|
|
2978
3298
|
}
|
|
2979
3299
|
catch (err) {
|
|
2980
3300
|
bot.manager.logger.log(`Failed to prepare create op for ${action.id}: ${getErrorMessage(err)}`, 'error');
|
|
@@ -2985,9 +3305,29 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2985
3305
|
if (action.newGridId && action.newGridId !== action.id) {
|
|
2986
3306
|
const masterOrder = bot.manager.orders.get(action.id);
|
|
2987
3307
|
const orderType = action.order?.type || masterOrder?.type;
|
|
2988
|
-
|
|
3308
|
+
// ROTATION PRICE IS THE DESTINATION'S GENESIS LEVEL.
|
|
3309
|
+
//
|
|
3310
|
+
// A rotation re-prices to the destination slot, so the
|
|
3311
|
+
// destination's level is the authoritative price — not
|
|
3312
|
+
// action.newPrice, which the planner copies from
|
|
3313
|
+
// hole.order.price and which is therefore only as sound
|
|
3314
|
+
// as whatever wrote that object. Deriving it here means a
|
|
3315
|
+
// planner bug cannot produce a mis-priced UPDATE even if
|
|
3316
|
+
// the invariant check below were bypassed; the check
|
|
3317
|
+
// stays as the backstop that catches a missing genesis
|
|
3318
|
+
// ladder (where it fails open) rather than the only
|
|
3319
|
+
// thing standing between a bad plan and a live order.
|
|
3320
|
+
const derivedNewPrice = deriveRotationPrice(bot, action.newGridId);
|
|
3321
|
+
const plannedNewPrice = Number.isFinite(Number(action.newPrice))
|
|
2989
3322
|
? Number(action.newPrice)
|
|
2990
3323
|
: Number(action.order?.price);
|
|
3324
|
+
let newPrice = Number.isFinite(derivedNewPrice) ? derivedNewPrice : plannedNewPrice;
|
|
3325
|
+
if (Number.isFinite(derivedNewPrice) && Number.isFinite(plannedNewPrice)
|
|
3326
|
+
&& Math.abs(derivedNewPrice - plannedNewPrice) > Math.max(1e-12, Math.abs(derivedNewPrice) * 1e-9)) {
|
|
3327
|
+
bot.manager.logger.log(`[GRID-PRICE-INVARIANT] Rotation ${action.id} -> ${action.newGridId}: planned price ${Format.formatPrice6(plannedNewPrice)} ` +
|
|
3328
|
+
`differs from the destination's genesis level ${Format.formatPrice6(derivedNewPrice)} — ` +
|
|
3329
|
+
`emitting the genesis level`, 'warn');
|
|
3330
|
+
}
|
|
2991
3331
|
const newSize = plannedUpdateSize(action);
|
|
2992
3332
|
if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
|
|
2993
3333
|
continue;
|
|
@@ -3194,6 +3534,20 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3194
3534
|
}
|
|
3195
3535
|
catch (_e) { /* best-effort */ }
|
|
3196
3536
|
const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
|
|
3537
|
+
// GRID-PRICE-INVARIANT (blocking): the emitted price
|
|
3538
|
+
// is action.newPrice, which every planner derives from the
|
|
3539
|
+
// object named by action.newGridId (verified: grid.ts:1867,
|
|
3540
|
+
// utils/system.ts:1195, validate.ts:600/746). Pass the
|
|
3541
|
+
// destination id only — no source fallback, so a missing id
|
|
3542
|
+
// counts as unchecked instead of checking the wrong slot.
|
|
3543
|
+
if (!recordGridPriceInvariantCheck(bot, action.newGridId, newPrice, gridPriceInvariantStats, 'UPDATE')) {
|
|
3544
|
+
skippedUpdateCount++;
|
|
3545
|
+
if (action.id)
|
|
3546
|
+
skippedUpdateSlotIds.add(action.id);
|
|
3547
|
+
if (action.newGridId)
|
|
3548
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
3549
|
+
continue;
|
|
3550
|
+
}
|
|
3197
3551
|
const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
|
|
3198
3552
|
if (!buildResult) {
|
|
3199
3553
|
skippedUpdateCount++;
|
|
@@ -3261,10 +3615,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3261
3615
|
const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
|
|
3262
3616
|
? Math.abs(livePrice - plannedPrice)
|
|
3263
3617
|
: 0;
|
|
3264
|
-
const fbPrice =
|
|
3618
|
+
const fbPrice = plannedPrice;
|
|
3265
3619
|
if (priceDrift > 0) {
|
|
3620
|
+
// Same rationale as the primary CREATE path: emit
|
|
3621
|
+
// the planned price and report the divergence
|
|
3622
|
+
// rather than adopting a derived live price.
|
|
3266
3623
|
bot.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
|
|
3267
|
-
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})
|
|
3624
|
+
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
|
|
3625
|
+
`emitting planned price.`, 'warn');
|
|
3268
3626
|
}
|
|
3269
3627
|
const sizeCheck = validateOrderSizeForExecution(bot, fbSize, fbType, fbOrder, fbSize);
|
|
3270
3628
|
if (!sizeCheck.isValid) {
|
|
@@ -3321,6 +3679,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3321
3679
|
}
|
|
3322
3680
|
catch (_fbGuardErr) { /* guard is best-effort */ }
|
|
3323
3681
|
const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
|
|
3682
|
+
// GRID-PRICE-INVARIANT (blocking): fallback CREATE.
|
|
3683
|
+
if (!recordGridPriceInvariantCheck(bot, targetSlotId, fbPrice, gridPriceInvariantStats, 'CREATE-FALLBACK')) {
|
|
3684
|
+
continue;
|
|
3685
|
+
}
|
|
3324
3686
|
const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
|
|
3325
3687
|
if (fbResult) {
|
|
3326
3688
|
operations.push(fbResult.op);
|
|
@@ -3331,12 +3693,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3331
3693
|
args: { amountToSell: fbArgs.amountToSell, minToReceive: fbArgs.minToReceive },
|
|
3332
3694
|
finalInts: fbResult.finalInts
|
|
3333
3695
|
});
|
|
3334
|
-
recordPendingBroadcast(bot, {
|
|
3696
|
+
const fbRecordedFp = recordPendingBroadcast(bot, {
|
|
3335
3697
|
opIndex: operations.length - 1,
|
|
3336
3698
|
ctxIndex: opContexts.length - 1,
|
|
3337
3699
|
order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
|
|
3338
3700
|
finalInts: fbResult.finalInts
|
|
3339
3701
|
});
|
|
3702
|
+
if (fbRecordedFp)
|
|
3703
|
+
batchPendingFps.add(fbRecordedFp);
|
|
3340
3704
|
bot.manager.logger.log(`[COW] Recovered "not found" for ${action.id}: converted UPDATE to CREATE for slot ${targetSlotId}`, 'warn');
|
|
3341
3705
|
continue;
|
|
3342
3706
|
}
|
|
@@ -3350,6 +3714,73 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3350
3714
|
}
|
|
3351
3715
|
}
|
|
3352
3716
|
}
|
|
3717
|
+
// FINAL PRE-BROADCAST PIVOT GATE (2026-09-13 incident on a live
|
|
3718
|
+
// market-pair bot): a
|
|
3719
|
+
// fill queued AFTER the batch-start freeze but BEFORE broadcast
|
|
3720
|
+
// passes every per-action check on a stale pivot (freeze .745, fill
|
|
3721
|
+
// queued .765, broadcast .910). Re-check the BUILT ops against a
|
|
3722
|
+
// re-refreshed pivot here — after op-building, before the batch
|
|
3723
|
+
// summary (drops count as skipped, not passed), fund validation
|
|
3724
|
+
// (snapshot reflects filtered ops) and single-flight claim.
|
|
3725
|
+
// op indexes recorded during op-building are rebuilt from kept
|
|
3726
|
+
// contexts below, so any future reader sees live indexes.
|
|
3727
|
+
// The gate runs on its OWN stats object: its re-checks would
|
|
3728
|
+
// otherwise double-count the build loop's checked/passed/skipped
|
|
3729
|
+
// totals in the batch summary below. Gate contributions are
|
|
3730
|
+
// reported separately (gateChecked=... fields).
|
|
3731
|
+
const skippedUpdateCountRef = { count: 0 };
|
|
3732
|
+
const finalGateStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0, pivotOffGrid: 0 };
|
|
3733
|
+
let finalGate = null;
|
|
3734
|
+
try {
|
|
3735
|
+
finalGate = runFinalPivotGate(bot, operations, opContexts, {
|
|
3736
|
+
actions,
|
|
3737
|
+
cowResult,
|
|
3738
|
+
frozenPivot: frozenPivotAtBatchStart,
|
|
3739
|
+
frozenType: frozenTypeAtBatchStart,
|
|
3740
|
+
lastFillGuardStats: finalGateStats,
|
|
3741
|
+
skippedUpdateSlotIds,
|
|
3742
|
+
skippedCreateSlotIds,
|
|
3743
|
+
skippedUpdateCountRef,
|
|
3744
|
+
freezeQueueDepth,
|
|
3745
|
+
batchPendingFps,
|
|
3746
|
+
});
|
|
3747
|
+
if (finalGate.refreshed)
|
|
3748
|
+
lastFillGuardPivotRefreshed = true;
|
|
3749
|
+
if (finalGate.pivotChanged) {
|
|
3750
|
+
try {
|
|
3751
|
+
const fmtP = (v) => (v == null || !Number.isFinite(Number(v)) ? 'none' : Format.formatPrice6(Number(v)));
|
|
3752
|
+
bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Final gate: pivot moved under batch ` +
|
|
3753
|
+
`${fmtP(frozenPivotAtBatchStart)}(${frozenTypeAtBatchStart ?? 'cold'})` +
|
|
3754
|
+
`->${fmtP(bot.manager?._lastFilledPrice)}(${bot.manager?._lastFilledType ?? 'cold'}) ` +
|
|
3755
|
+
`(freezeQueue=${freezeQueueDepth ?? '?'}) ` +
|
|
3756
|
+
`dropped=${finalGate.dropped.length}`, 'warn');
|
|
3757
|
+
}
|
|
3758
|
+
catch { /* logging is best-effort */ }
|
|
3759
|
+
}
|
|
3760
|
+
// Rebuild cancelOpIndexByOrderId from the KEPT contexts: the
|
|
3761
|
+
// gate compacted operations/opContexts in lockstep, so indexes
|
|
3762
|
+
// recorded during op-building are stale for every op after the
|
|
3763
|
+
// first drop. cancelOpIndexByOrderId has NO readers past this
|
|
3764
|
+
// point (verified: the op-building loop is its last use —
|
|
3765
|
+
// pair-mode/chunk grouping is computed lazily at broadcast from
|
|
3766
|
+
// opContexts with no stored indexes), so this rebuild is
|
|
3767
|
+
// defence-in-depth for future readers, not a live fix.
|
|
3768
|
+
cancelOpIndexByOrderId.clear();
|
|
3769
|
+
for (let ci = 0; ci < opContexts.length; ci++) {
|
|
3770
|
+
const cctx = opContexts[ci];
|
|
3771
|
+
const cord = cctx?.order;
|
|
3772
|
+
const cOrderId = cctx?.kind === 'cancel'
|
|
3773
|
+
? cctx?.order?.orderId || cctx?.orderId
|
|
3774
|
+
: cord?.orderId;
|
|
3775
|
+
if (cOrderId)
|
|
3776
|
+
cancelOpIndexByOrderId.set(cOrderId, ci);
|
|
3777
|
+
}
|
|
3778
|
+
// skippedUpdateCount is threaded via countRef so the restore
|
|
3779
|
+
// below covers gate-dropped rotations too.
|
|
3780
|
+
if (skippedUpdateCountRef.count > 0)
|
|
3781
|
+
skippedUpdateCount += skippedUpdateCountRef.count;
|
|
3782
|
+
}
|
|
3783
|
+
catch (_gateErr) { /* gate is fail-open: keep the built ops */ }
|
|
3353
3784
|
// Batch-level LAST-FILL-GUARD summary: per-action pass lines would spam
|
|
3354
3785
|
// big batches, so one line per batch records the mode, pivot, resolved
|
|
3355
3786
|
// increment, and pass/skip/bypass counts — the guard's pass decisions
|
|
@@ -3361,7 +3792,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3361
3792
|
// for the whole batch, so every action was checked against the same
|
|
3362
3793
|
// pivot printed here.
|
|
3363
3794
|
try {
|
|
3364
|
-
|
|
3795
|
+
// The final gate reports on its OWN counters (finalGateStats), so
|
|
3796
|
+
// checked/passed/skipped/bypassed here are the build loop's
|
|
3797
|
+
// verdicts only — the gate's re-checks never inflate them. The
|
|
3798
|
+
// gate's contributions ride along as gate* fields, omitted when
|
|
3799
|
+
// the gate did not re-check anything (same convention as
|
|
3800
|
+
// pivotOffGrid above). totalGuarded includes the gate so a
|
|
3801
|
+
// batch that was ONLY gate-checked (e.g. cold freeze armed
|
|
3802
|
+
// mid-batch) still prints.
|
|
3803
|
+
const gateGuarded = finalGateStats.checked + finalGateStats.bypassed;
|
|
3804
|
+
const totalGuarded = lastFillGuardStats.checked + lastFillGuardStats.bypassed + gateGuarded;
|
|
3365
3805
|
if (totalGuarded > 0) {
|
|
3366
3806
|
const sumPivotRaw = bot.manager?._lastFilledPrice;
|
|
3367
3807
|
const sumType = bot.manager?._lastFilledType;
|
|
@@ -3376,13 +3816,39 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3376
3816
|
? `active+bypassed(${batchOrigin || 'unknown'})`
|
|
3377
3817
|
: 'active');
|
|
3378
3818
|
const pivotStr = cold ? 'none' : `${Format.formatPrice6(Number(sumPivotRaw))}(${sumType})`;
|
|
3379
|
-
|
|
3819
|
+
// Report when the pivot used for this batch was NOT a ladder
|
|
3820
|
+
// level: that is the precondition for a pivoted ratchet, and
|
|
3821
|
+
// the value alone does not reveal it.
|
|
3822
|
+
const batchPivot = cold ? null : resolveOnGridPivot(bot.manager, sumPivotRaw);
|
|
3823
|
+
const pivotGridStr = cold
|
|
3824
|
+
? ''
|
|
3825
|
+
: (batchPivot && batchPivot.idx != null
|
|
3826
|
+
? ` pivotSlot=${batchPivot.idx}${batchPivot.snapped ? '(snapped)' : ''}`
|
|
3827
|
+
: ' pivotOffGrid=true');
|
|
3828
|
+
bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr}${pivotGridStr} inc=${sumInc}% ` +
|
|
3380
3829
|
`pivotRefreshed=${lastFillGuardPivotRefreshed} ` +
|
|
3381
3830
|
`checked=${lastFillGuardStats.checked} passed=${lastFillGuardStats.passed} ` +
|
|
3382
|
-
`skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}
|
|
3831
|
+
`skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}` +
|
|
3832
|
+
// Per-action off-ladder count. The batch-level
|
|
3833
|
+
// `pivotOffGrid=true` flag above only says the pivot was
|
|
3834
|
+
// off-grid; this says HOW MANY guarded probes judged a
|
|
3835
|
+
// placement against an unsnapped pivot, which is the
|
|
3836
|
+
// quantity that grows during the ratchet this guard
|
|
3837
|
+
// exists to catch. Omitted (not `0`) when never set, so
|
|
3838
|
+
// "no off-grid probes" stays distinguishable from
|
|
3839
|
+
// "counter unavailable".
|
|
3840
|
+
(Number(lastFillGuardStats.pivotOffGrid) > 0
|
|
3841
|
+
? ` pivotOffGrid=${lastFillGuardStats.pivotOffGrid}`
|
|
3842
|
+
: '') +
|
|
3843
|
+
(gateGuarded > 0
|
|
3844
|
+
? ` gateChecked=${finalGateStats.checked} gatePassed=${finalGateStats.passed} ` +
|
|
3845
|
+
`gateSkipped=${finalGateStats.skipped} gateBypassed=${finalGateStats.bypassed}`
|
|
3846
|
+
: ''), cold || (batchPivot && batchPivot.idx == null) ? 'warn' : 'info');
|
|
3383
3847
|
}
|
|
3384
3848
|
}
|
|
3385
3849
|
catch { /* summary is best-effort */ }
|
|
3850
|
+
// Batch-level GRID-PRICE-INVARIANT summary (rejected emissions).
|
|
3851
|
+
logGridPriceInvariantSummary(bot, gridPriceInvariantStats, 'COW');
|
|
3386
3852
|
if (skippedUpdateCount > 0) {
|
|
3387
3853
|
restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
|
|
3388
3854
|
}
|
|
@@ -3466,7 +3932,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3466
3932
|
});
|
|
3467
3933
|
if (!preBroadcastGuard.canCommit) {
|
|
3468
3934
|
// Bounded re-plan + proceed — policy documented on replanStaleBatch.
|
|
3469
|
-
const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard);
|
|
3935
|
+
const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs);
|
|
3470
3936
|
if (replan.handled) {
|
|
3471
3937
|
return replan.result;
|
|
3472
3938
|
}
|
|
@@ -3997,6 +4463,68 @@ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, cont
|
|
|
3997
4463
|
await persistGridAndClearPendingBroadcasts(bot, logPrefix);
|
|
3998
4464
|
return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
|
|
3999
4465
|
}
|
|
4466
|
+
/**
|
|
4467
|
+
* Resolve the LAST-FILL guard pivot to an ON-GRID price.
|
|
4468
|
+
*
|
|
4469
|
+
* The pivot was a raw fill price, and nothing validated it. That made it a
|
|
4470
|
+
* ratchet input: any bad pivot (a corrupt fill price, an adopted off-grid
|
|
4471
|
+
* order) shifted every subsequent threshold, so an off-market buy could read
|
|
4472
|
+
* as legitimate. A price is only meaningful relative to the ladder it trades
|
|
4473
|
+
* on, so the pivot is converted to its nearest slot index and back to that
|
|
4474
|
+
* slot's genesis price: the result is by construction a real grid level and
|
|
4475
|
+
* cannot drift off the ladder.
|
|
4476
|
+
*
|
|
4477
|
+
* Falls back to the raw price when genesis is unavailable (pre-genesis
|
|
4478
|
+
* startup), so the guard degrades to its previous behaviour rather than
|
|
4479
|
+
* silently disabling. Never throws.
|
|
4480
|
+
*
|
|
4481
|
+
* @param {any} manager
|
|
4482
|
+
* @param {number|null|undefined} rawPrice
|
|
4483
|
+
* @returns {{price: number|null, snapped: boolean, idx: number|null, nearestDrift: number|null}}
|
|
4484
|
+
*/
|
|
4485
|
+
function resolveOnGridPivot(manager, rawPrice) {
|
|
4486
|
+
const price = Number(rawPrice);
|
|
4487
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
4488
|
+
return { price: null, snapped: false, idx: null, nearestDrift: null };
|
|
4489
|
+
try {
|
|
4490
|
+
const genesis = manager?._genesis;
|
|
4491
|
+
const levels = genesis?.priceLevels;
|
|
4492
|
+
if (!Array.isArray(levels) || levels.length === 0)
|
|
4493
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4494
|
+
const idx = math.slotIndexForPrice(price, genesis);
|
|
4495
|
+
if (!Number.isFinite(idx))
|
|
4496
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4497
|
+
// Only accept a genuine ladder level and only a nearest match. A price
|
|
4498
|
+
// that is wildly off-ladder (an adopted orphan far outside the grid)
|
|
4499
|
+
// must NOT be silently rewritten onto an edge slot -- that would make a
|
|
4500
|
+
// bad pivot look like a legitimate grid fill. Leave it snapped=false and
|
|
4501
|
+
// let the caller decide.
|
|
4502
|
+
const candidate = Number(math.priceForSlot(idx, genesis));
|
|
4503
|
+
if (!Number.isFinite(candidate) || candidate <= 0)
|
|
4504
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4505
|
+
// Reject a snap that moves the pivot by more than one increment: any
|
|
4506
|
+
// in-grid fill is within half an increment of its slot price, so a
|
|
4507
|
+
// larger move means the pivot itself is not a real fill price.
|
|
4508
|
+
const drift = Math.abs(candidate - price) / price;
|
|
4509
|
+
// resolveLastFillGuardIncrement takes a BOT-shaped argument and reads
|
|
4510
|
+
// `bot.manager.config` first. resolveOnGridPivot only ever has the
|
|
4511
|
+
// manager (no bot in scope), so wrap it to satisfy that contract.
|
|
4512
|
+
// Passing the manager directly would silently skip its own tuning and
|
|
4513
|
+
// fall through to DEFAULT_CONFIG, making the snap tolerance wrong for
|
|
4514
|
+
// any grid not built on the default increment.
|
|
4515
|
+
const increment = resolveLastFillGuardIncrement({ manager }) / 100;
|
|
4516
|
+
const maxDrift = Number.isFinite(increment) && increment > 0 ? increment : 0.005;
|
|
4517
|
+
// nearestDrift is reported even when the snap is refused, so a caller
|
|
4518
|
+
// can tell a near-miss (rounding, one increment out) from a price that
|
|
4519
|
+
// is nowhere near the ladder (a genuinely corrupt pivot).
|
|
4520
|
+
if (drift > maxDrift)
|
|
4521
|
+
return { price, snapped: false, idx: null, nearestDrift: drift };
|
|
4522
|
+
return { price: candidate, snapped: drift > 0, idx, nearestDrift: drift };
|
|
4523
|
+
}
|
|
4524
|
+
catch {
|
|
4525
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4000
4528
|
/**
|
|
4001
4529
|
* Run the last-fill guard probe: optionally refresh the pivot from
|
|
4002
4530
|
* still-queued fills, read the durable pivot, and evaluate
|
|
@@ -4005,6 +4533,13 @@ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, cont
|
|
|
4005
4533
|
* skip logging stay at the call sites, which differ per action kind).
|
|
4006
4534
|
* Batch callers pass skipRefresh=true: the batch-start freeze owns refreshes
|
|
4007
4535
|
* so every action in a batch is judged against the same pivot.
|
|
4536
|
+
*
|
|
4537
|
+
* FINAL-GATE CONTRACT (see runFinalPivotGate): the gate re-checks BUILT ops
|
|
4538
|
+
* against a re-refreshed pivot AFTER the op-building loop. It must run BEFORE
|
|
4539
|
+
* any later mutation of operations/opContexts (fund validation snapshot,
|
|
4540
|
+
* pair-mode/chunk grouping) — those stages index op positions and read stale
|
|
4541
|
+
* indexes after a filter. Call sites after the gate must treat
|
|
4542
|
+
* operations/opContexts as the filtered arrays.
|
|
4008
4543
|
* @param {Object} bot
|
|
4009
4544
|
* @param {number} price - Target order price
|
|
4010
4545
|
* @param {number} size - Order size
|
|
@@ -4027,10 +4562,384 @@ function runLastFillGuardCheck(bot, price, size, type, stats, skipRefresh = fals
|
|
|
4027
4562
|
const lastPrice = bot.manager?._lastFilledPrice;
|
|
4028
4563
|
const lastType = bot.manager?._lastFilledType;
|
|
4029
4564
|
const inc = resolveLastFillGuardIncrement(bot);
|
|
4030
|
-
|
|
4565
|
+
// Validate the pivot onto the ladder before use (see resolveOnGridPivot).
|
|
4566
|
+
// Reported once per probe at warn when the raw pivot was NOT a grid level:
|
|
4567
|
+
// that is the ratchet precondition, and it is otherwise invisible because
|
|
4568
|
+
// the pivot is only ever logged by value.
|
|
4569
|
+
const onGrid = resolveOnGridPivot(bot.manager, lastPrice);
|
|
4570
|
+
if (lastPrice != null && onGrid.idx == null && Number.isFinite(Number(lastPrice))) {
|
|
4571
|
+
try {
|
|
4572
|
+
// A persistently off-ladder pivot is exactly the corruption case, so
|
|
4573
|
+
// this branch would otherwise warn once per guarded action per batch
|
|
4574
|
+
// on top of the batch summary (which already reports pivotOffGrid).
|
|
4575
|
+
// Warn once per distinct pivot value per batch: repeated identical
|
|
4576
|
+
// pivots are the same condition, and a CHANGED pivot still warns.
|
|
4577
|
+
const warnedKey = `lastFillPivotWarned:${bot?._currentCycleId ?? 'na'}`;
|
|
4578
|
+
const alreadyWarned = bot[warnedKey];
|
|
4579
|
+
if (alreadyWarned !== Number(lastPrice)) {
|
|
4580
|
+
bot[warnedKey] = Number(lastPrice);
|
|
4581
|
+
// Distinguish "close to a level but too far to snap" from "nowhere
|
|
4582
|
+
// near the ladder". The former is a rounding/drift artifact; the
|
|
4583
|
+
// latter means the pivot itself is not a real fill price.
|
|
4584
|
+
const far = onGrid.nearestDrift == null || onGrid.nearestDrift > 0.02;
|
|
4585
|
+
const kind = far ? 'off-ladder' : 'near-ladder'
|
|
4586
|
+
+ (onGrid.nearestDrift != null ? ` (${(onGrid.nearestDrift * 100).toFixed(4)}% from the nearest level)` : '');
|
|
4587
|
+
bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Pivot ${Format.formatPrice6(Number(lastPrice))} is not a grid level ` +
|
|
4588
|
+
`(${kind}); using raw value. A non-grid pivot can ` +
|
|
4589
|
+
`misjudge which placements are off-market. ` +
|
|
4590
|
+
`(reported once per batch per pivot value)`, 'warn');
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4593
|
+
catch { /* logging is best-effort */ }
|
|
4594
|
+
stats.pivotOffGrid = (stats.pivotOffGrid || 0) + 1;
|
|
4595
|
+
}
|
|
4596
|
+
const check = isLastFillGuardBlocked(price, size, type, onGrid.price, lastType, inc);
|
|
4031
4597
|
stats.checked++;
|
|
4032
4598
|
return { check, refreshed };
|
|
4033
4599
|
}
|
|
4600
|
+
/**
|
|
4601
|
+
* Final pre-broadcast pivot gate: re-check BUILT ops against a re-refreshed
|
|
4602
|
+
* pivot RIGHT BEFORE broadcast.
|
|
4603
|
+
*
|
|
4604
|
+
* Why this exists (2026-09-13 incident on a live market-pair bot): the batch-start freeze
|
|
4605
|
+
* refreshes the pivot once, then every CREATE / rotation-UPDATE /
|
|
4606
|
+
* fallback-CREATE is judged against that frozen value (skipRefresh=true).
|
|
4607
|
+
* A fill that is queued AFTER the freeze but BEFORE broadcast (in the
|
|
4608
|
+
* incident: freeze at .745, fill queued at .765, broadcast at .910) passes
|
|
4609
|
+
* every per-action check on a stale pivot and ships. The batch summary
|
|
4610
|
+
* prints pivotRefreshed=false — the freeze honestly found nothing — and
|
|
4611
|
+
* the violating ops broadcast anyway.
|
|
4612
|
+
*
|
|
4613
|
+
* Placement (see FINAL-GATE CONTRACT on runLastFillGuardCheck):
|
|
4614
|
+
* 1. Called after the op-building action loop, BEFORE the LAST-FILL-GUARD
|
|
4615
|
+
* batch summary line (so dropped ops are visible as skipped, not
|
|
4616
|
+
* passed) and BEFORE fund validation (so the VALIDATION snapshot
|
|
4617
|
+
* reflects the filtered ops).
|
|
4618
|
+
* 2. Must run before pair-mode/chunk grouping, which indexes op positions.
|
|
4619
|
+
* executeOperationsWithStrategy groups lazily at broadcast time from
|
|
4620
|
+
* the (filtered) opContexts it receives, so filtering here is safe —
|
|
4621
|
+
* but any future grouping computed between op-building and broadcast
|
|
4622
|
+
* must be rebuilt after the gate (same rule as the contract).
|
|
4623
|
+
*
|
|
4624
|
+
* Semantics:
|
|
4625
|
+
* - Peek-only refresh (never drains the fill queue — same as the freeze).
|
|
4626
|
+
* - Pivot UNCHANGED since the freeze => pure no-op: returns the input
|
|
4627
|
+
* arrays untouched, no extra log lines beyond queue-depth debug.
|
|
4628
|
+
* - Pivot CHANGED => re-run isLastFillGuardBlocked against each built op's
|
|
4629
|
+
* final price with the SAME bypass rules as the build loop
|
|
4630
|
+
* (spread-correction CREATEs, stamped gap-evacuation UPDATEs). Violating
|
|
4631
|
+
* ops + their contexts are dropped; their slots feed the existing
|
|
4632
|
+
* skippedUpdateSlotIds/skippedCreateSlotIds restore paths so the working
|
|
4633
|
+
* grid stays consistent (dropped rotations restore from master, dropped
|
|
4634
|
+
* creates count toward the boundary-hold intersect).
|
|
4635
|
+
* - fail-open on everything unjudgeable: unresolvable price/type, cold
|
|
4636
|
+
* pivot (null), or a refresh/inference throw => the op is KEPT. A gate
|
|
4637
|
+
* that cannot prove a violation must not invent one — dropping a healthy
|
|
4638
|
+
* op strands its slot, while a missed violation is still caught by the
|
|
4639
|
+
* next cycle's guard + commit chain adoption.
|
|
4640
|
+
* - size-update ops are NEVER gated (same-price, no repricing).
|
|
4641
|
+
* - cancel ops are NEVER gated or dropped.
|
|
4642
|
+
*
|
|
4643
|
+
* Stale-index hygiene: recordPendingBroadcast stores opIndex/ctxIndex
|
|
4644
|
+
* against the build-time arrays. Dropped CREATEs must have their pending
|
|
4645
|
+
* entries removed (else the reconcile path adopts a broadcast that never
|
|
4646
|
+
* shipped), and KEPT entries must have their stored indexes REMAPPED: a
|
|
4647
|
+
* lockstep compaction keeps operations/opContexts aligned with each other,
|
|
4648
|
+
* but the absolute indexes stored inside the pending entries are not
|
|
4649
|
+
* rewritten by it — after the first drop, an unremapped ctxIndex resolves
|
|
4650
|
+
* to a shifted position (undefined at best, a DIFFERENT create's context at
|
|
4651
|
+
* worst, which would let adoptMatchedEntries synchronize the wrong slot
|
|
4652
|
+
* with a matched chain order). The gate therefore builds an old→new index
|
|
4653
|
+
* map during compaction and remaps/removes entries identified by
|
|
4654
|
+
* opts.batchPendingFps (the fingerprints THIS batch recorded — entry.batchId
|
|
4655
|
+
* is always null in production, so it cannot discriminate). Sibling
|
|
4656
|
+
* batches' entries are never touched (their indexes refer to their own
|
|
4657
|
+
* build-time arrays).
|
|
4658
|
+
*
|
|
4659
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
4660
|
+
* @param {Array} operations - Built chain ops (mutated in place on drop)
|
|
4661
|
+
* @param {Array} opContexts - Built op contexts (mutated in place on drop)
|
|
4662
|
+
* @param {Object} opts - { actions, cowResult, frozenPivot, frozenType,
|
|
4663
|
+
* lastFillGuardStats, skippedUpdateSlotIds, skippedCreateSlotIds,
|
|
4664
|
+
* skippedUpdateCountRef: { count }, freezeQueueDepth, batchPendingFps }
|
|
4665
|
+
* @returns {{ dropped: Array, pivotChanged: boolean, refreshed: boolean }}
|
|
4666
|
+
*/
|
|
4667
|
+
function runFinalPivotGate(bot, operations, opContexts, opts = {}) {
|
|
4668
|
+
const empty = { dropped: [], pivotChanged: false, refreshed: false };
|
|
4669
|
+
try {
|
|
4670
|
+
if (!Array.isArray(operations) || !Array.isArray(opContexts) || operations.length === 0)
|
|
4671
|
+
return empty;
|
|
4672
|
+
const stats = opts?.lastFillGuardStats;
|
|
4673
|
+
const frozenPivot = Number(opts?.frozenPivot);
|
|
4674
|
+
const frozenType = opts?.frozenType;
|
|
4675
|
+
const frozenCold = !Number.isFinite(frozenPivot) || frozenType == null;
|
|
4676
|
+
const queueDepthBefore = Array.isArray(bot?._incomingFillQueue)
|
|
4677
|
+
? bot._incomingFillQueue.length
|
|
4678
|
+
: null;
|
|
4679
|
+
// Peek-only re-refresh: never drains the queue (same as the freeze).
|
|
4680
|
+
let refreshed = false;
|
|
4681
|
+
try {
|
|
4682
|
+
refreshed = !!refreshLastFillPivotFromQueue(bot);
|
|
4683
|
+
}
|
|
4684
|
+
catch {
|
|
4685
|
+
refreshed = false;
|
|
4686
|
+
}
|
|
4687
|
+
const queueDepthAfter = Array.isArray(bot?._incomingFillQueue)
|
|
4688
|
+
? bot._incomingFillQueue.length
|
|
4689
|
+
: null;
|
|
4690
|
+
const livePivot = Number(bot.manager?._lastFilledPrice);
|
|
4691
|
+
const liveType = bot.manager?._lastFilledType;
|
|
4692
|
+
const liveCold = !Number.isFinite(livePivot) || liveType == null;
|
|
4693
|
+
// No-op fast path: pivot unchanged (or uncomparable) since the freeze.
|
|
4694
|
+
// frozenCold + liveCold: guard stayed disabled — nothing to re-check.
|
|
4695
|
+
// frozenCold + liveArmed: the freeze ran cold but a fill arrived
|
|
4696
|
+
// mid-batch. The built ops were NEVER guarded; treat as changed so
|
|
4697
|
+
// they are checked below (fail-open keeps whatever is unjudgeable).
|
|
4698
|
+
let pivotChanged = refreshed;
|
|
4699
|
+
if (frozenCold && liveCold)
|
|
4700
|
+
pivotChanged = false;
|
|
4701
|
+
else if (!frozenCold && !liveCold)
|
|
4702
|
+
pivotChanged = livePivot !== frozenPivot || liveType !== frozenType;
|
|
4703
|
+
else
|
|
4704
|
+
pivotChanged = true;
|
|
4705
|
+
try {
|
|
4706
|
+
bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Final gate: queue ${queueDepthBefore ?? '?'}->${queueDepthAfter ?? '?'} ` +
|
|
4707
|
+
`pivot ${Number.isFinite(frozenPivot) ? Format.formatPrice6(frozenPivot) : 'none'}(${frozenType ?? 'cold'})` +
|
|
4708
|
+
`->${Number.isFinite(livePivot) ? Format.formatPrice6(livePivot) : 'none'}(${liveType ?? 'cold'}) ` +
|
|
4709
|
+
`changed=${pivotChanged} refreshed=${refreshed}`, 'debug');
|
|
4710
|
+
}
|
|
4711
|
+
catch { /* logging is best-effort */ }
|
|
4712
|
+
if (!pivotChanged)
|
|
4713
|
+
return { ...empty, refreshed };
|
|
4714
|
+
// Pivot moved (or armed mid-batch): re-check every built op's FINAL
|
|
4715
|
+
// price. Same bypass rules as the build loop; unjudgeable => KEEP.
|
|
4716
|
+
const actions = Array.isArray(opts?.actions) ? opts.actions : [];
|
|
4717
|
+
const batchOrigin = opts?.cowResult?.origin;
|
|
4718
|
+
const actionBySlot = new Map();
|
|
4719
|
+
for (const a of actions) {
|
|
4720
|
+
if (!a)
|
|
4721
|
+
continue;
|
|
4722
|
+
// Rotation UPDATEs are keyed by DESTINATION slot (the emitted
|
|
4723
|
+
// price is the destination's level); plain CREATEs by slot id.
|
|
4724
|
+
const rotDest = a?.newGridId;
|
|
4725
|
+
const key = (a?.type === COW_ACTIONS.UPDATE && rotDest && rotDest !== a?.id) ? rotDest : a?.id;
|
|
4726
|
+
if (key && !actionBySlot.has(key))
|
|
4727
|
+
actionBySlot.set(key, a);
|
|
4728
|
+
}
|
|
4729
|
+
const dropIdx = new Set();
|
|
4730
|
+
const dropped = [];
|
|
4731
|
+
const inc = resolveLastFillGuardIncrement(bot);
|
|
4732
|
+
const onGrid = resolveOnGridPivot(bot.manager, bot.manager?._lastFilledPrice);
|
|
4733
|
+
for (let i = 0; i < opContexts.length; i++) {
|
|
4734
|
+
const ctx = opContexts[i];
|
|
4735
|
+
if (!ctx || ctx.kind === 'cancel' || ctx.kind === 'size-update')
|
|
4736
|
+
continue;
|
|
4737
|
+
let price = null;
|
|
4738
|
+
let type = null;
|
|
4739
|
+
let size = null;
|
|
4740
|
+
let slotId = null;
|
|
4741
|
+
let action = null;
|
|
4742
|
+
if (ctx.kind === 'create') {
|
|
4743
|
+
slotId = ctx.id || ctx.order?.id || null;
|
|
4744
|
+
price = Number(ctx.order?.price);
|
|
4745
|
+
type = ctx.order?.type || null;
|
|
4746
|
+
size = Number(ctx.order?.size);
|
|
4747
|
+
action = (slotId && actionBySlot.get(slotId)) || null;
|
|
4748
|
+
// Spread-correction CREATE bypass (mirrors the build loop:
|
|
4749
|
+
// per-action origin, batch origin as back-compat fallback).
|
|
4750
|
+
const actionOrigin = action?.origin;
|
|
4751
|
+
if (actionOrigin === 'spread-correction'
|
|
4752
|
+
|| (actionOrigin == null && batchOrigin === 'spread-correction')) {
|
|
4753
|
+
if (stats)
|
|
4754
|
+
stats.bypassed = (Number(stats.bypassed) || 0) + 1;
|
|
4755
|
+
continue;
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
else if (ctx.kind === 'rotation') {
|
|
4759
|
+
const rot = ctx.rotation || {};
|
|
4760
|
+
slotId = rot.newGridId || rot.oldOrder?.id || null;
|
|
4761
|
+
price = Number(rot.newPrice);
|
|
4762
|
+
type = rot.type || null;
|
|
4763
|
+
size = Number(rot.newSize);
|
|
4764
|
+
// Rotation UPDATEs are keyed by DESTINATION slot (the
|
|
4765
|
+
// emitted price is the destination's level) — EXCEPT the
|
|
4766
|
+
// same-slot size-only form (no newGridId, or newGridId ===
|
|
4767
|
+
// source id), which buildActionsFromPlan emits for
|
|
4768
|
+
// ordersToUpdate and which must resolve to the source
|
|
4769
|
+
// action. A same-slot UPDATE carries no repricing, so a
|
|
4770
|
+
// dest-keyed lookup that misses it would ALSO miss its
|
|
4771
|
+
// origin stamp — fall back to the source id before
|
|
4772
|
+
// judging the bypass.
|
|
4773
|
+
action = (slotId && actionBySlot.get(slotId)) || null;
|
|
4774
|
+
if (!action) {
|
|
4775
|
+
const srcId = rot.oldOrder?.id || null;
|
|
4776
|
+
if (srcId)
|
|
4777
|
+
action = actionBySlot.get(srcId) || null;
|
|
4778
|
+
}
|
|
4779
|
+
// Gap-evacuation UPDATE bypass mirrors the build loop's
|
|
4780
|
+
// stamped path ONLY — with one deliberate asymmetry (see
|
|
4781
|
+
// below): the build loop re-proves UNSTAMPED evacuations
|
|
4782
|
+
// live from the master grid; the gate guards them normally.
|
|
4783
|
+
// An unstamped rotation reaching the final gate was either
|
|
4784
|
+
// (a) probed-and-allowed at build time — in which case its
|
|
4785
|
+
// price already survived an evacuation proof and the guard
|
|
4786
|
+
// re-check here is harmless duplication, or (b) probe-
|
|
4787
|
+
// rejected/failed-closed — in which case it was SKIPPED at
|
|
4788
|
+
// build time and never reached op-building, so the gate
|
|
4789
|
+
// cannot see it either. Either way there is no live
|
|
4790
|
+
// unstamped evacuation in the built ops that needs
|
|
4791
|
+
// re-proving: re-proving here would need the master-grid
|
|
4792
|
+
// source read the build loop does, and the source may have
|
|
4793
|
+
// been pre-applied since. Stamped rotations carry origin +
|
|
4794
|
+
// evacBoundary/evacGapSlots.
|
|
4795
|
+
//
|
|
4796
|
+
// ASYMMETRY (fail-open, not fail-closed): the build loop's
|
|
4797
|
+
// unstamped path FAILS CLOSED (unresolvable source => skip
|
|
4798
|
+
// the op). The gate FAILS OPEN (unjudgeable => keep). A
|
|
4799
|
+
// dropped op strands its slot until the next cycle; a kept
|
|
4800
|
+
// op is still subject to the commit guard + chain adoption.
|
|
4801
|
+
// The gate must not invent a block it cannot prove —
|
|
4802
|
+
// especially not on an op the build loop already allowed.
|
|
4803
|
+
const rotOrigin = action?.origin;
|
|
4804
|
+
if (rotOrigin === 'gap-evacuation'
|
|
4805
|
+
&& Number.isFinite(Number(action?.evacBoundary))
|
|
4806
|
+
&& Number.isFinite(Number(action?.evacGapSlots))) {
|
|
4807
|
+
if (stats)
|
|
4808
|
+
stats.bypassed = (Number(stats.bypassed) || 0) + 1;
|
|
4809
|
+
continue;
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
else {
|
|
4813
|
+
continue;
|
|
4814
|
+
}
|
|
4815
|
+
// Fail-open: unresolvable price/type => KEEP (never invent a
|
|
4816
|
+
// violation the gate cannot prove).
|
|
4817
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
4818
|
+
continue;
|
|
4819
|
+
if (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL)
|
|
4820
|
+
continue;
|
|
4821
|
+
const check = isLastFillGuardBlocked(price, size, type, onGrid.price, liveType, inc);
|
|
4822
|
+
if (stats)
|
|
4823
|
+
stats.checked = (Number(stats.checked) || 0) + 1;
|
|
4824
|
+
if (!check.blocked) {
|
|
4825
|
+
if (stats)
|
|
4826
|
+
stats.passed = (Number(stats.passed) || 0) + 1;
|
|
4827
|
+
continue;
|
|
4828
|
+
}
|
|
4829
|
+
// Blocked: drop the op + context, restore the slot below.
|
|
4830
|
+
dropIdx.add(i);
|
|
4831
|
+
if (stats)
|
|
4832
|
+
stats.skipped = (Number(stats.skipped) || 0) + 1;
|
|
4833
|
+
const dir = type === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
4834
|
+
dropped.push({ index: i, kind: ctx.kind, slotId, price, type });
|
|
4835
|
+
try {
|
|
4836
|
+
bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Final gate dropping ${type} ${ctx.kind} for ${slotId ?? 'unknown'} at ` +
|
|
4837
|
+
`${Format.formatPrice6(price)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} ` +
|
|
4838
|
+
`(halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)}); re-planned after market moves`, 'warn');
|
|
4839
|
+
}
|
|
4840
|
+
catch { /* logging is best-effort */ }
|
|
4841
|
+
}
|
|
4842
|
+
if (dropIdx.size === 0)
|
|
4843
|
+
return { dropped, pivotChanged, refreshed };
|
|
4844
|
+
// Compact operations/opContexts in lockstep so every surviving index
|
|
4845
|
+
// still lines up. The caller rebuilds cancelOpIndexByOrderId from the
|
|
4846
|
+
// kept contexts (it was built during op-building and goes stale for
|
|
4847
|
+
// every op after the first drop), and the remap pass below re-points
|
|
4848
|
+
// THIS batch's kept pending-broadcast entries at their new positions
|
|
4849
|
+
// — lockstep keeps the two arrays aligned with each other, but the
|
|
4850
|
+
// absolute indexes stored INSIDE pending entries are not rewritten
|
|
4851
|
+
// by a compaction, so they must be remapped explicitly.
|
|
4852
|
+
const keptOps = [];
|
|
4853
|
+
const keptCtxs = [];
|
|
4854
|
+
const oldToNew = new Map();
|
|
4855
|
+
for (let i = 0, ni = 0; i < opContexts.length; i++) {
|
|
4856
|
+
if (dropIdx.has(i))
|
|
4857
|
+
continue;
|
|
4858
|
+
oldToNew.set(i, ni);
|
|
4859
|
+
ni++;
|
|
4860
|
+
keptOps.push(operations[i]);
|
|
4861
|
+
keptCtxs.push(opContexts[i]);
|
|
4862
|
+
}
|
|
4863
|
+
operations.length = 0;
|
|
4864
|
+
operations.push(...keptOps);
|
|
4865
|
+
opContexts.length = 0;
|
|
4866
|
+
opContexts.push(...keptCtxs);
|
|
4867
|
+
// Slot restore: dropped rotations restore source+dest from master
|
|
4868
|
+
// (same sets the build loop feeds to restoreSkippedUpdateSlots...);
|
|
4869
|
+
// dropped creates count toward the refill-hold intersect. Pending
|
|
4870
|
+
// entries for dropped CREATEs are removed (never shipped); kept
|
|
4871
|
+
// CREATEs get their stored opIndex/ctxIndex remapped to the
|
|
4872
|
+
// compacted positions (see the remap pass below — an unremapped
|
|
4873
|
+
// absolute index would resolve to a SHIFTED context after the first
|
|
4874
|
+
// drop: undefined at best, a DIFFERENT create's context at worst,
|
|
4875
|
+
// which would let the uncertain-broadcast reconcile adopt a matched
|
|
4876
|
+
// chain order into the wrong slot).
|
|
4877
|
+
const skippedUpdateSlotIds = opts?.skippedUpdateSlotIds;
|
|
4878
|
+
const skippedCreateSlotIds = opts?.skippedCreateSlotIds;
|
|
4879
|
+
const countRef = opts?.skippedUpdateCountRef;
|
|
4880
|
+
try {
|
|
4881
|
+
const pending = bot.manager?._pendingBroadcasts;
|
|
4882
|
+
for (const d of dropped) {
|
|
4883
|
+
if (d.kind === 'rotation') {
|
|
4884
|
+
const act = (d.slotId && actionBySlot.get(d.slotId)) || null;
|
|
4885
|
+
const srcId = act?.id || null;
|
|
4886
|
+
if (srcId && skippedUpdateSlotIds instanceof Set)
|
|
4887
|
+
skippedUpdateSlotIds.add(srcId);
|
|
4888
|
+
if (d.slotId && skippedUpdateSlotIds instanceof Set)
|
|
4889
|
+
skippedUpdateSlotIds.add(d.slotId);
|
|
4890
|
+
if (countRef && typeof countRef === 'object')
|
|
4891
|
+
countRef.count = (Number(countRef.count) || 0) + 1;
|
|
4892
|
+
}
|
|
4893
|
+
else if (d.kind === 'create') {
|
|
4894
|
+
if (d.slotId && skippedCreateSlotIds instanceof Set)
|
|
4895
|
+
skippedCreateSlotIds.add(d.slotId);
|
|
4896
|
+
if (pending instanceof Map) {
|
|
4897
|
+
for (const [fp, entry] of pending) {
|
|
4898
|
+
// Match by slot only: the fingerprint embeds
|
|
4899
|
+
// side/amounts/slot (no op indexes), but entry.slotId
|
|
4900
|
+
// is the narrowest predicate that cannot touch a
|
|
4901
|
+
// sibling batch's entry for a different slot.
|
|
4902
|
+
if (entry?.slotId && entry.slotId === d.slotId) {
|
|
4903
|
+
pending.delete(fp);
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
// Remap THIS batch's kept pending entries (identified by the
|
|
4910
|
+
// fingerprint set the build loop collected — entry.batchId is
|
|
4911
|
+
// always null in production and cannot discriminate). A stored
|
|
4912
|
+
// ctxIndex whose old position was dropped means the entry was
|
|
4913
|
+
// not slot-matched above; it could never resolve post-compaction,
|
|
4914
|
+
// so it is removed. Everything else is re-pointed at the same
|
|
4915
|
+
// context object it was recorded with. Sibling batches' entries
|
|
4916
|
+
// are never touched: their indexes refer to their own long-gone
|
|
4917
|
+
// build-time arrays.
|
|
4918
|
+
if (pending instanceof Map && opts?.batchPendingFps instanceof Set) {
|
|
4919
|
+
for (const fp of opts.batchPendingFps) {
|
|
4920
|
+
const entry = pending.get(fp);
|
|
4921
|
+
if (!entry)
|
|
4922
|
+
continue; // dropped create: already removed by slot
|
|
4923
|
+
const newCtx = oldToNew.get(Number(entry.ctxIndex));
|
|
4924
|
+
if (newCtx == null) {
|
|
4925
|
+
// Referenced op was dropped (or the entry predates
|
|
4926
|
+
// lockstep indexing) — the index cannot be healed.
|
|
4927
|
+
pending.delete(fp);
|
|
4928
|
+
continue;
|
|
4929
|
+
}
|
|
4930
|
+
entry.ctxIndex = newCtx;
|
|
4931
|
+
const newOp = oldToNew.get(Number(entry.opIndex));
|
|
4932
|
+
entry.opIndex = newOp == null ? entry.opIndex : newOp;
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
catch { /* restore bookkeeping is best-effort */ }
|
|
4937
|
+
return { dropped, pivotChanged, refreshed };
|
|
4938
|
+
}
|
|
4939
|
+
catch {
|
|
4940
|
+
return empty;
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4034
4943
|
/**
|
|
4035
4944
|
* Apply BTS create-fee accounting for a batch that bypassed the normal
|
|
4036
4945
|
* processBatchResults pipeline (commit refused after broadcast, or
|
|
@@ -4273,7 +5182,7 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
4273
5182
|
updateOperationCount
|
|
4274
5183
|
};
|
|
4275
5184
|
}
|
|
4276
|
-
export { isLastFillGuardBlocked, refreshLastFillPivotFromQueue, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet, trackBoundaryHold };
|
|
5185
|
+
export { isLastFillGuardBlocked, resolveOnGridPivot, checkGridPriceInvariant, deriveRotationPrice, refreshLastFillPivotFromQueue, runFinalPivotGate, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet, trackBoundaryHold };
|
|
4277
5186
|
// Exported for regression tests (issue #23 sibling): the uncertain-broadcast
|
|
4278
5187
|
// discard path must never drop a placement silently when master lost the slot.
|
|
4279
5188
|
export { restoreDiscardedCreates };
|
|
@@ -4312,5 +5221,6 @@ export default {
|
|
|
4312
5221
|
pollChainForConfirmation,
|
|
4313
5222
|
updateOrdersOnChainBatchCOW,
|
|
4314
5223
|
processBatchResults,
|
|
5224
|
+
runFinalPivotGate,
|
|
4315
5225
|
};
|
|
4316
5226
|
//# sourceMappingURL=dexbot_cow_runtime.js.map
|