dexbot 1.5.1 → 1.5.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 +35 -0
- package/analysis/ama_fitting/package.json +1 -1
- package/analysis/resolve_source.ts +3 -0
- 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/README.md +5 -1
- package/analysis/tradingview/analyze_tradingview.ts +27 -1
- 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 +351 -22
- 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/resolve_source.d.ts.map +1 -1
- package/dist/analysis/resolve_source.js +3 -0
- package/dist/analysis/resolve_source.js.map +1 -1
- package/dist/analysis/tradingview/analyze_tradingview.d.ts +3 -0
- package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
- package/dist/analysis/tradingview/analyze_tradingview.js +27 -2
- package/dist/analysis/tradingview/analyze_tradingview.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 +348 -22
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
- package/dist/dexbot.js +5 -5
- package/dist/dexbot.js.map +1 -1
- package/dist/market_adapter/core/asymmetric_bounds.d.ts +33 -1
- package/dist/market_adapter/core/asymmetric_bounds.d.ts.map +1 -1
- package/dist/market_adapter/core/asymmetric_bounds.js +38 -1
- package/dist/market_adapter/core/asymmetric_bounds.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +2 -18
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +38 -37
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.js +722 -557
- package/dist/modules/dexbot_cow_runtime.js.map +1 -1
- package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_fill_runtime.js +7 -1
- package/dist/modules/dexbot_fill_runtime.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +31 -51
- 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 +42 -3
- package/dist/modules/dexbot_startup_runtime.js.map +1 -1
- package/dist/modules/order/accounting.d.ts.map +1 -1
- package/dist/modules/order/accounting.js +19 -2
- package/dist/modules/order/accounting.js.map +1 -1
- package/dist/modules/order/export.d.ts +122 -15
- package/dist/modules/order/export.d.ts.map +1 -1
- package/dist/modules/order/export.js +310 -46
- package/dist/modules/order/export.js.map +1 -1
- package/dist/modules/order/grid.d.ts.map +1 -1
- package/dist/modules/order/grid.js +29 -38
- package/dist/modules/order/grid.js.map +1 -1
- package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile.js +7 -3
- package/dist/modules/order/grid_reconcile.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.js +31 -33
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
- package/dist/modules/order/manager.d.ts +13 -6
- package/dist/modules/order/manager.d.ts.map +1 -1
- package/dist/modules/order/manager.js +22 -7
- 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 +231 -11
- package/dist/modules/order/sync_engine.js.map +1 -1
- package/dist/modules/order/utils/math.d.ts +38 -15
- package/dist/modules/order/utils/math.d.ts.map +1 -1
- package/dist/modules/order/utils/math.js +55 -16
- package/dist/modules/order/utils/math.js.map +1 -1
- package/dist/modules/order/utils/order.d.ts +116 -20
- package/dist/modules/order/utils/order.d.ts.map +1 -1
- package/dist/modules/order/utils/order.js +340 -35
- package/dist/modules/order/utils/order.js.map +1 -1
- package/dist/modules/order/utils/system.d.ts +1 -30
- package/dist/modules/order/utils/system.d.ts.map +1 -1
- package/dist/modules/order/utils/system.js +31 -98
- package/dist/modules/order/utils/system.js.map +1 -1
- package/dist/modules/order/utils/validate.d.ts +3 -2
- package/dist/modules/order/utils/validate.d.ts.map +1 -1
- package/dist/modules/order/utils/validate.js +49 -28
- package/dist/modules/order/utils/validate.js.map +1 -1
- package/dist/scripts/analyze-credit.d.ts.map +1 -1
- package/dist/scripts/analyze-credit.js +53 -33
- package/dist/scripts/analyze-credit.js.map +1 -1
- package/docs/DEXBOT_COMPARISON.md +3 -3
- package/docs/EVOLUTION.md +7 -5
- package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
- package/docs/README.md +2 -2
- package/docs/WORKFLOW.md +1 -1
- package/package.json +1 -1
|
@@ -14,11 +14,12 @@ import { BroadcastUncertainError as BroadcastUncertainErrorBinding } from './dex
|
|
|
14
14
|
const BroadcastUncertainError = BroadcastUncertainErrorBinding;
|
|
15
15
|
import * as orderUtils from './order/utils/order.js';
|
|
16
16
|
import { sleep } from './order/utils/system.js';
|
|
17
|
-
const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, buildOutsideInPairGroups, isOrderPlaced, } = orderUtils;
|
|
17
|
+
const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, buildOutsideInPairGroups, isOrderPlaced, chainOrderUnchangedFromCache, detectCrossedBookPlan, collectKnownOnChainOrderIds, } = orderUtils;
|
|
18
18
|
import * as validate from './order/utils/validate.js';
|
|
19
19
|
const { validateCreateTargetSlots, evaluateCommit, hasExecutableActions, stampGapEvacuationRotation } = validate;
|
|
20
20
|
import * as math from './order/utils/math.js';
|
|
21
|
-
const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand } = math;
|
|
21
|
+
const { validateOrderSize, findCrossedOrder, priceSlotEqual, isEvacuationRotationAllowed, isEvacuationSizeStillValid, getSellStartIdx, getPrecisionByOrderType, isSlotIndexInGapBand, getAssetFeesSafe, blockchainToFloat, floatToBlockchainInt, quantizeFloat } = math;
|
|
22
|
+
import { parseSlotIndex } from './order/utils/slot.js';
|
|
22
23
|
/**
|
|
23
24
|
* Re-verify a B-stamped gap-evacuation rotation against the LIVE committed
|
|
24
25
|
* geometry (boundaryIdx/_gapSlots at execution time). The stamp freezes
|
|
@@ -48,7 +49,6 @@ export function isEvacuationStampStillValid(liveBoundary, liveGapSlots, sourceId
|
|
|
48
49
|
const dstInRail = orderType === ORDER_TYPES.SELL ? Number(dstIdx) >= sellStartIdx : Number(dstIdx) <= b;
|
|
49
50
|
return dstInRail;
|
|
50
51
|
}
|
|
51
|
-
import { parseSlotIndex } from './order/utils/slot.js';
|
|
52
52
|
function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predicate) {
|
|
53
53
|
for (const it of items) {
|
|
54
54
|
if (predicate && !predicate(it))
|
|
@@ -69,8 +69,6 @@ function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predica
|
|
|
69
69
|
}
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
|
-
// Lazy accessor so test mocks on the math module export take effect at call time.
|
|
73
|
-
function getAssetFeesSafe(...args) { return require('./order/utils/math').getAssetFeesSafe(...args); }
|
|
74
72
|
import * as constantsModule from './constants.js';
|
|
75
73
|
const { COW_ACTIONS, COW_PERFORMANCE, ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, } = constantsModule;
|
|
76
74
|
import { acquireIfNotHeld } from './order/async_lock.js';
|
|
@@ -79,8 +77,6 @@ const Format = FormatModule;
|
|
|
79
77
|
import * as workingGridModule from './order/working_grid.js';
|
|
80
78
|
const { WorkingGrid } = workingGridModule;
|
|
81
79
|
import { getErrorMessage } from './utils/errors.js';
|
|
82
|
-
import { createRequire } from 'node:module';
|
|
83
|
-
const require = createRequire(import.meta.url);
|
|
84
80
|
// Maximum number of times the pre-broadcast staleness guard may re-plan the
|
|
85
81
|
// batch from a fresh master before proceeding anyway. Bounded so a master
|
|
86
82
|
// grid that keeps mutating (fill bursts, sync loops) can never livelock the
|
|
@@ -99,11 +95,7 @@ const SINGLE_FLIGHT_MAX_WAIT_MS = 120000;
|
|
|
99
95
|
* @returns {Array<Array>}
|
|
100
96
|
*/
|
|
101
97
|
function buildOutsideInPairGroupsForOrders(orders) {
|
|
102
|
-
return
|
|
103
|
-
isValid: Boolean,
|
|
104
|
-
getType: (o) => o.type,
|
|
105
|
-
getPrice: (o) => o.price,
|
|
106
|
-
});
|
|
98
|
+
return buildOutsideInPairGroupsWithAdapter(orders, (o) => o);
|
|
107
99
|
}
|
|
108
100
|
/**
|
|
109
101
|
* Build outside-in pair groups for create entry contexts.
|
|
@@ -111,12 +103,128 @@ function buildOutsideInPairGroupsForOrders(orders) {
|
|
|
111
103
|
* @returns {Array<Array>}
|
|
112
104
|
*/
|
|
113
105
|
function buildOutsideInPairGroupsForCreateEntries(createEntries) {
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
return buildOutsideInPairGroupsWithAdapter(createEntries, (e) => e?.context?.order);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Shared outside-in delegation: project each item to its order, then group
|
|
110
|
+
* with the single order adapter. The ForOrders/ForCreateEntries wrappers
|
|
111
|
+
* differ only in this projection.
|
|
112
|
+
* @param {Array} items
|
|
113
|
+
* @param {Function} toOrder - Project an item to its order object
|
|
114
|
+
* @returns {Array<Array>}
|
|
115
|
+
*/
|
|
116
|
+
function buildOutsideInPairGroupsWithAdapter(items, toOrder) {
|
|
117
|
+
return buildOutsideInPairGroups(items, {
|
|
118
|
+
isValid: (item) => Boolean(toOrder(item)),
|
|
119
|
+
getType: (item) => toOrder(item)?.type,
|
|
120
|
+
getPrice: (item) => toOrder(item)?.price,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create a create-op fingerprint for a specific slot, used for
|
|
125
|
+
* pending-broadcast matching and chain-order fingerprinting.
|
|
126
|
+
* Consolidates the fingerprint construction pattern found in
|
|
127
|
+
* recordPendingBroadcast, buildChainOrderFingerprint, findChainOrderForSlot,
|
|
128
|
+
* and verifyCreateAbsent.
|
|
129
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
130
|
+
* @param {Object} order - The order object
|
|
131
|
+
* @param {Object} finalInts - The final integer tuple { sell, receive, sellAssetId, receiveAssetId }
|
|
132
|
+
* @param {string} slotId - The slot identifier
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
function createOpFingerprintForSlot(bot, order, finalInts, slotId) {
|
|
136
|
+
if (!order || !finalInts || !slotId)
|
|
137
|
+
return null;
|
|
138
|
+
return buildCreateOpFingerprint({
|
|
139
|
+
side: order.type,
|
|
140
|
+
assetA: bot.manager?.assets?.assetA?.id,
|
|
141
|
+
assetB: bot.manager?.assets?.assetB?.id,
|
|
142
|
+
sellInt: finalInts.sell,
|
|
143
|
+
receiveInt: finalInts.receive,
|
|
144
|
+
slotId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Read the pending-broadcast entries as an array. The manager map is
|
|
149
|
+
* created lazily by recordPendingBroadcast, so every reader must guard
|
|
150
|
+
* on `instanceof Map` — this helper is the single spelling of that guard.
|
|
151
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
152
|
+
* @returns {Array}
|
|
153
|
+
*/
|
|
154
|
+
function getPendingBroadcasts(bot) {
|
|
155
|
+
return (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
|
|
156
|
+
? Array.from(bot.manager._pendingBroadcasts.values())
|
|
157
|
+
: [];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Count pending-broadcast entries without materializing the array.
|
|
161
|
+
* Same instanceof Map guard as getPendingBroadcasts, for hot paths that
|
|
162
|
+
* only need the count (e.g. the orphan auto-cancel gate).
|
|
163
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
164
|
+
* @returns {number}
|
|
165
|
+
*/
|
|
166
|
+
function countPendingBroadcasts(bot) {
|
|
167
|
+
return (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
|
|
168
|
+
? bot.manager._pendingBroadcasts.size
|
|
169
|
+
: 0;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Probe the chain snapshot for a pending-broadcast entry's slot.
|
|
173
|
+
* Single spelling of the findChainOrderForSlot probe literal used by
|
|
174
|
+
* reconcile (first pass + re-read), the poll confirmer, and the
|
|
175
|
+
* create-absence verifier.
|
|
176
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
177
|
+
* @param {Array} chainSnapshot
|
|
178
|
+
* @param {Object} entry - Pending entry ({ slotId, finalInts, orderType, fingerprint })
|
|
179
|
+
* @returns {Object|null}
|
|
180
|
+
*/
|
|
181
|
+
function findChainOrderForPendingEntry(bot, chainSnapshot, entry) {
|
|
182
|
+
if (!entry?.slotId)
|
|
183
|
+
return null;
|
|
184
|
+
return findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
|
|
185
|
+
sell: entry.finalInts?.sell,
|
|
186
|
+
receive: entry.finalInts?.receive,
|
|
187
|
+
orderType: entry.orderType,
|
|
188
|
+
fingerprint: entry.fingerprint,
|
|
118
189
|
});
|
|
119
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Check whether a chain read is authoritative enough to make
|
|
193
|
+
* definitive absence/lPresence decisions. An authoritative read is
|
|
194
|
+
* non-empty, non-truncated, and non-null.
|
|
195
|
+
* @param {Object} readResult - The result of readOpenOrdersWithMeta
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
function isAuthoritativeChainRead(readResult) {
|
|
199
|
+
if (!readResult || !Array.isArray(readResult.orders))
|
|
200
|
+
return false;
|
|
201
|
+
if (readResult.truncated)
|
|
202
|
+
return false;
|
|
203
|
+
return readResult.orders.length > 0;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Build a rawOnChain metadata object from final integer values.
|
|
207
|
+
* Used in processBatchResults for size-update, create, and rotation
|
|
208
|
+
* contexts to enrich order objects with on-chain identity.
|
|
209
|
+
* Returns null when the order id (or final ints) is missing: downstream
|
|
210
|
+
* chainOrderUnchangedFromCache treats a null cache as "not unchanged" and
|
|
211
|
+
* defers, which is the safer direction versus comparing an id-less object.
|
|
212
|
+
* @param {string} orderId - The on-chain order id
|
|
213
|
+
* @param {Object} finalInts - The final integer tuple
|
|
214
|
+
* @returns {Object|null}
|
|
215
|
+
*/
|
|
216
|
+
function rawOnChainFromInts(orderId, finalInts) {
|
|
217
|
+
if (!orderId || !finalInts)
|
|
218
|
+
return null;
|
|
219
|
+
return {
|
|
220
|
+
id: orderId,
|
|
221
|
+
for_sale: String(finalInts.sell),
|
|
222
|
+
sell_price: {
|
|
223
|
+
base: { amount: String(finalInts.sell), asset_id: finalInts.sellAssetId },
|
|
224
|
+
quote: { amount: String(finalInts.receive), asset_id: finalInts.receiveAssetId },
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
120
228
|
/**
|
|
121
229
|
* Extract operation results from a batch transaction result.
|
|
122
230
|
* @param {Object|Array|null} result
|
|
@@ -222,14 +330,7 @@ function recordPendingBroadcast(bot, entry) {
|
|
|
222
330
|
if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
|
|
223
331
|
bot.manager._pendingBroadcasts = new Map();
|
|
224
332
|
}
|
|
225
|
-
const fingerprint =
|
|
226
|
-
side: entry.order.type,
|
|
227
|
-
assetA: bot.manager?.assets?.assetA?.id,
|
|
228
|
-
assetB: bot.manager?.assets?.assetB?.id,
|
|
229
|
-
sellInt: entry.finalInts?.sell,
|
|
230
|
-
receiveInt: entry.finalInts?.receive,
|
|
231
|
-
slotId: entry.order.id
|
|
232
|
-
});
|
|
333
|
+
const fingerprint = createOpFingerprintForSlot(bot, entry.order, entry.finalInts, entry.order.id);
|
|
233
334
|
if (!fingerprint) {
|
|
234
335
|
bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
|
|
235
336
|
return;
|
|
@@ -280,28 +381,18 @@ function crossedOrderLabel(crossed) {
|
|
|
280
381
|
* an UPDATE-only rotation batch can re-price across an un-adopted chain
|
|
281
382
|
* order that master-grid-only checks cannot see (the pending/unmatched
|
|
282
383
|
* batch guards fire only for CREATE batches).
|
|
384
|
+
*
|
|
385
|
+
* Delegates to the shared order-utils builder so every crossing guard (COW
|
|
386
|
+
* create/rotation/fallback, startup reconcile placement, price-correction)
|
|
387
|
+
* sees the same set. Pending entries are pushed as wrappers (slotId +
|
|
388
|
+
* order): their inner order has no chain id yet, so orderId-only
|
|
389
|
+
* predicates would blind the guard to them — the shared
|
|
390
|
+
* isCrossingCheckCandidate predicate accepts slot-id-only wrappers.
|
|
283
391
|
* @param {Object} bot
|
|
284
392
|
* @returns {any[]}
|
|
285
393
|
*/
|
|
286
394
|
function buildCrossingCandidates(bot) {
|
|
287
|
-
|
|
288
|
-
if (!mgr)
|
|
289
|
-
return [];
|
|
290
|
-
const candidates = mgr.orders instanceof Map ? [...mgr.orders.values()] : [];
|
|
291
|
-
if (mgr._pendingBroadcasts instanceof Map) {
|
|
292
|
-
for (const entry of mgr._pendingBroadcasts.values()) {
|
|
293
|
-
const o = entry?.order;
|
|
294
|
-
if (o && o.type != null && o.price != null)
|
|
295
|
-
candidates.push(o);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (Array.isArray(mgr._lastUnmatchedChainOrders)) {
|
|
299
|
-
for (const o of mgr._lastUnmatchedChainOrders) {
|
|
300
|
-
if (o && o.type != null && o.price != null)
|
|
301
|
-
candidates.push(o);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return candidates;
|
|
395
|
+
return orderUtils.buildCrossingCheckCandidates(bot?.manager);
|
|
305
396
|
}
|
|
306
397
|
/**
|
|
307
398
|
* Drop only the pending-broadcast entries for the given CREATE slots.
|
|
@@ -520,72 +611,24 @@ async function reconcileAfterUncertainBroadcast(bot, err, opContexts, options =
|
|
|
520
611
|
return acquireIfNotHeld(bot.manager?._fillProcessingLock, () => reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, options));
|
|
521
612
|
}
|
|
522
613
|
/**
|
|
523
|
-
*
|
|
614
|
+
* Match pending broadcasts against a chain snapshot (first pass by slot
|
|
615
|
+
* probe, second pass by fingerprint across all pending slots), then
|
|
616
|
+
* re-read the chain for discarded CREATEs to close the TOCTOU window.
|
|
617
|
+
* Returns `{ adopted, discarded }`, or `{ deferred }` when the re-read
|
|
618
|
+
* is ambiguous/failed and the caller must defer (pending protection kept).
|
|
524
619
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
525
|
-
* @param {
|
|
526
|
-
* @param {Array
|
|
527
|
-
* @param {
|
|
528
|
-
* @
|
|
620
|
+
* @param {Array} pending
|
|
621
|
+
* @param {Array} chainSnapshot
|
|
622
|
+
* @param {Array} opContexts
|
|
623
|
+
* @param {*} accountRef
|
|
624
|
+
* @param {*} err
|
|
529
625
|
*/
|
|
530
|
-
async function
|
|
531
|
-
const startedAt = Date.now();
|
|
532
|
-
const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
|
|
533
|
-
? Array.from(bot.manager._pendingBroadcasts.values())
|
|
534
|
-
: [];
|
|
535
|
-
const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
|
|
536
|
-
const nonCreateContextCount = opContexts.length - createContextCount;
|
|
537
|
-
bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
|
|
538
|
-
`creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
|
|
539
|
-
`staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
|
|
540
|
-
if (!chainOrders?.readOpenOrdersWithMeta) {
|
|
541
|
-
bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
|
|
542
|
-
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
543
|
-
await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
|
|
544
|
-
}
|
|
545
|
-
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
546
|
-
return { executed: false, hadRotation: false, uncertain: true };
|
|
547
|
-
}
|
|
548
|
-
// 1. Read the chain
|
|
549
|
-
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
550
|
-
let chainSnapshot = [];
|
|
551
|
-
let chainReadTruncated = false;
|
|
552
|
-
try {
|
|
553
|
-
const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
|
|
554
|
-
chainSnapshot = chainRead.orders;
|
|
555
|
-
chainReadTruncated = chainRead.truncated;
|
|
556
|
-
}
|
|
557
|
-
catch (readErr) {
|
|
558
|
-
bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
|
|
559
|
-
`Falling back to structural resync.`, 'error');
|
|
560
|
-
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
561
|
-
await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
|
|
562
|
-
}
|
|
563
|
-
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
564
|
-
return { executed: false, hadRotation: false, uncertain: true };
|
|
565
|
-
}
|
|
566
|
-
// 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
|
|
567
|
-
// account is either genuinely empty or the node is lagging behind the
|
|
568
|
-
// just-broadcast transaction. A truncated snapshot (get_full_accounts
|
|
569
|
-
// capped limit_orders; fresh creates sort last in the by_account index
|
|
570
|
-
// and are the first entries omitted) is equally ambiguous: the batch's
|
|
571
|
-
// creates may simply be missing from the returned window. Treating every
|
|
572
|
-
// pending broadcast as discarded would clear the pending-broadcast
|
|
573
|
-
// protection and let the next cycle re-CREATE slots whose orders may
|
|
574
|
-
// actually be on chain (duplicate orders). Keep the protection and let
|
|
575
|
-
// the structural resync adopt any landed orders.
|
|
576
|
-
if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
|
|
577
|
-
return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
|
|
578
|
-
}
|
|
626
|
+
async function matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err) {
|
|
579
627
|
const adopted = [];
|
|
580
628
|
let discarded = [];
|
|
581
629
|
// 2. For each pending broadcast, look for a chain match.
|
|
582
630
|
for (const entry of pending) {
|
|
583
|
-
const match =
|
|
584
|
-
sell: entry.finalInts?.sell,
|
|
585
|
-
receive: entry.finalInts?.receive,
|
|
586
|
-
orderType: entry.orderType,
|
|
587
|
-
fingerprint: entry.fingerprint,
|
|
588
|
-
});
|
|
631
|
+
const match = findChainOrderForPendingEntry(bot, chainSnapshot, entry);
|
|
589
632
|
if (match) {
|
|
590
633
|
adopted.push({ entry, match });
|
|
591
634
|
}
|
|
@@ -634,21 +677,18 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
634
677
|
// protection and let the next cycle re-create (duplicate) an
|
|
635
678
|
// order that actually landed in the TOCTOU window. Keep the
|
|
636
679
|
// pending-broadcast protection and defer to a structural resync.
|
|
637
|
-
if (!
|
|
680
|
+
if (!isAuthoritativeChainRead(freshRead)) {
|
|
638
681
|
const ambiguous = !freshRead || !Array.isArray(freshRead.orders) || freshRead.orders.length === 0;
|
|
639
|
-
return
|
|
682
|
+
return {
|
|
683
|
+
deferred: await deferUncertainBroadcastRead(bot, `${ambiguous ? 'Empty' : 'Truncated'} re-read for ${createDiscarded.length} discarded CREATE(s)`, '(absence is not authoritative on an ambiguous re-read)', 'uncertain broadcast — ambiguous re-read for discarded creates', { batchId: err?.batchId || null, truncated: !ambiguous })
|
|
684
|
+
};
|
|
640
685
|
}
|
|
641
686
|
const freshChain = freshRead.orders;
|
|
642
687
|
const remainingDiscarded = [];
|
|
643
688
|
for (const entry of discarded) {
|
|
644
689
|
const ctx = opContexts[entry.ctxIndex];
|
|
645
690
|
if (ctx && ctx.kind === 'create') {
|
|
646
|
-
const match =
|
|
647
|
-
sell: entry.finalInts?.sell,
|
|
648
|
-
receive: entry.finalInts?.receive,
|
|
649
|
-
orderType: entry.orderType,
|
|
650
|
-
fingerprint: entry.fingerprint,
|
|
651
|
-
});
|
|
691
|
+
const match = findChainOrderForPendingEntry(bot, freshChain, entry);
|
|
652
692
|
if (match) {
|
|
653
693
|
adopted.push({ entry, match });
|
|
654
694
|
bot.manager.logger.log(`[COW][UNCERTAIN] Late-adopted discarded CREATE for slot ${entry.slotId} (${match.id}) via fresh chain read`, 'info');
|
|
@@ -664,13 +704,24 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
664
704
|
discarded = remainingDiscarded;
|
|
665
705
|
}
|
|
666
706
|
catch (reReadErr) {
|
|
667
|
-
return
|
|
707
|
+
return {
|
|
708
|
+
deferred: await deferUncertainBroadcastRead(bot, `Fresh chain read for late adoption FAILED (${getErrorMessage(reReadErr)})`, '(absence is not authoritative on a failed re-read)', 'uncertain broadcast — failed re-read for discarded creates', { batchId: err?.batchId || null })
|
|
709
|
+
};
|
|
668
710
|
}
|
|
669
711
|
}
|
|
670
712
|
}
|
|
671
|
-
|
|
713
|
+
return { adopted, discarded };
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Adopt matched pending broadcasts into the master grid (CREATE slots
|
|
717
|
+
* synchronize with the chain) and clear their pending entries.
|
|
718
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
719
|
+
* @param {Array} adopted
|
|
720
|
+
* @param {Array} opContexts
|
|
721
|
+
* @returns {Promise<number>} adoptedCount
|
|
722
|
+
*/
|
|
723
|
+
async function adoptMatchedEntries(bot, adopted, opContexts) {
|
|
672
724
|
let adoptedCount = 0;
|
|
673
|
-
let discardedCount = 0;
|
|
674
725
|
for (const { entry, match } of adopted) {
|
|
675
726
|
adoptedCount++;
|
|
676
727
|
const plannedOpCtx = opContexts[entry.ctxIndex];
|
|
@@ -684,6 +735,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
684
735
|
chainOrderId,
|
|
685
736
|
expectedType,
|
|
686
737
|
fee: btsFeeData?.createFee || 0,
|
|
738
|
+
order: plannedOpCtx.order ?? entry.order ?? null,
|
|
687
739
|
}, 'createOrder');
|
|
688
740
|
}
|
|
689
741
|
catch (syncErr) {
|
|
@@ -695,6 +747,19 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
695
747
|
bot.manager._pendingBroadcasts.delete(entry.fingerprint);
|
|
696
748
|
}
|
|
697
749
|
}
|
|
750
|
+
return adoptedCount;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Restore discarded CREATE slots to creation-uncertain state and clear
|
|
754
|
+
* their pending entries; residual pending entries that survive the
|
|
755
|
+
* decide phase are cleaned defensively.
|
|
756
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
757
|
+
* @param {Array} discarded
|
|
758
|
+
* @param {Array} opContexts
|
|
759
|
+
* @returns {Promise<number>} discardedCount
|
|
760
|
+
*/
|
|
761
|
+
async function restoreDiscardedCreates(bot, discarded, opContexts) {
|
|
762
|
+
let discardedCount = 0;
|
|
698
763
|
for (const entry of discarded) {
|
|
699
764
|
discardedCount++;
|
|
700
765
|
const plannedOpCtx = opContexts[entry.ctxIndex];
|
|
@@ -771,12 +836,48 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
771
836
|
}
|
|
772
837
|
}
|
|
773
838
|
}
|
|
839
|
+
else {
|
|
840
|
+
// Slot missing from master (grid reset raced the uncertain
|
|
841
|
+
// broadcast). Deleting the pending entry below without
|
|
842
|
+
// restoring anything would leave a clean hole the next
|
|
843
|
+
// cycle re-CREATEs — duplicating a possibly-landed order.
|
|
844
|
+
// Materialize the creation-uncertain slot from the
|
|
845
|
+
// broadcast-time descriptor so the next sync's orphan
|
|
846
|
+
// adoption reconciles a landed order into it.
|
|
847
|
+
const missingType = entry.order.type;
|
|
848
|
+
if (missingType === ORDER_TYPES.BUY || missingType === ORDER_TYPES.SELL) {
|
|
849
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard — materializing creation-uncertain state ` +
|
|
850
|
+
`(type=${missingType}, size: ${entry.order.size}); next sync adoption will reconcile any landed order`, 'warn');
|
|
851
|
+
const updates = [{
|
|
852
|
+
id: entry.order.id,
|
|
853
|
+
type: missingType,
|
|
854
|
+
size: entry.order.size,
|
|
855
|
+
price: entry.order.price,
|
|
856
|
+
state: ORDER_STATES.VIRTUAL,
|
|
857
|
+
createUncertain: true,
|
|
858
|
+
orderId: null,
|
|
859
|
+
rawOnChain: null,
|
|
860
|
+
}];
|
|
861
|
+
if (typeof bot.manager.applyGridUpdateBatch === 'function') {
|
|
862
|
+
await bot.manager.applyGridUpdateBatch(updates, 'uncertain-broadcast-discard-restore');
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Slot ${entry.order.id} missing from master after discard with unrecognized type ${missingType} — cannot reconstruct creation-uncertain state; next sync must adopt any landed order as an orphan`, 'error');
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Discarded CREATE for slot ${entry.slotId} has no usable placement descriptor (id=${entry.order?.id ?? 'none'}, size=${entry.order?.size ?? 'none'}, type=${entry.order?.type ?? 'none'}) — nothing restored; next sync must adopt any landed order as an orphan`, 'error');
|
|
774
872
|
}
|
|
775
873
|
}
|
|
776
874
|
catch (restoreErr) {
|
|
777
875
|
bot.manager.logger.log(`[COW][UNCERTAIN] Failed to restore slot ${entry.slotId} after discard: ${restoreErr?.message || restoreErr}`, 'error');
|
|
778
876
|
}
|
|
779
877
|
}
|
|
878
|
+
else {
|
|
879
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Discarded pending broadcast for slot ${entry.slotId} is not a recognizable CREATE (no create opContext, no order id/type) — skipping restore; next sync must adopt any landed order as an orphan`, 'error');
|
|
880
|
+
}
|
|
780
881
|
// Remove from pending broadcasts.
|
|
781
882
|
if (entry.fingerprint && bot.manager._pendingBroadcasts?.has(entry.fingerprint)) {
|
|
782
883
|
bot.manager._pendingBroadcasts.delete(entry.fingerprint);
|
|
@@ -796,6 +897,101 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
796
897
|
}
|
|
797
898
|
bot.manager._pendingBroadcasts.clear();
|
|
798
899
|
}
|
|
900
|
+
return discardedCount;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Request a structural resync when chain orders remain unaccounted for
|
|
904
|
+
* after the uncertain-broadcast reconciliation.
|
|
905
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
906
|
+
* @param {*} err
|
|
907
|
+
* @param {Array} pending
|
|
908
|
+
* @param {Array} chainSnapshot
|
|
909
|
+
* @param {Array} adopted
|
|
910
|
+
* @param {number} adoptedCount
|
|
911
|
+
* @param {number} discardedCount
|
|
912
|
+
*/
|
|
913
|
+
async function resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount) {
|
|
914
|
+
// 7. Request structural resync if any chain orders remain unaccounted for
|
|
915
|
+
// after the reconciliation, ensuring the next cycle re-plans from a clean
|
|
916
|
+
// chain snapshot.
|
|
917
|
+
const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
|
|
918
|
+
if (!alreadyScheduled && chainSnapshot.length > 0) {
|
|
919
|
+
const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
|
|
920
|
+
const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
|
|
921
|
+
if (unreconciledCount > 0) {
|
|
922
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
|
|
923
|
+
`These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
|
|
924
|
+
await requestStructuralResync(bot, 'unreconciled orders after uncertain broadcast', {
|
|
925
|
+
batchId: err?.batchId || null,
|
|
926
|
+
pendingCount: pending.length,
|
|
927
|
+
adoptedCount,
|
|
928
|
+
discardedCount,
|
|
929
|
+
unreconciledCount
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Reconcile a broadcast whose chain state is unknown (implementation).
|
|
936
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
937
|
+
* @param {BroadcastUncertainError} err
|
|
938
|
+
* @param {Array<Object>} opContexts
|
|
939
|
+
* @param {Object} options
|
|
940
|
+
* @returns {Promise<Object>}
|
|
941
|
+
*/
|
|
942
|
+
async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _options) {
|
|
943
|
+
const startedAt = Date.now();
|
|
944
|
+
const pending = getPendingBroadcasts(bot);
|
|
945
|
+
const createContextCount = opContexts.filter((c) => c && c.kind === 'create').length;
|
|
946
|
+
const nonCreateContextCount = opContexts.length - createContextCount;
|
|
947
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
|
|
948
|
+
`creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
|
|
949
|
+
`staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
|
|
950
|
+
if (!chainOrders?.readOpenOrdersWithMeta) {
|
|
951
|
+
bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrdersWithMeta unavailable; falling back to structural resync only.', 'error');
|
|
952
|
+
await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
|
|
953
|
+
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
954
|
+
return { executed: false, hadRotation: false, uncertain: true };
|
|
955
|
+
}
|
|
956
|
+
// 1. Read the chain
|
|
957
|
+
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
958
|
+
let chainSnapshot = [];
|
|
959
|
+
let chainReadTruncated = false;
|
|
960
|
+
try {
|
|
961
|
+
const chainRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
|
|
962
|
+
chainSnapshot = chainRead.orders;
|
|
963
|
+
chainReadTruncated = chainRead.truncated;
|
|
964
|
+
}
|
|
965
|
+
catch (readErr) {
|
|
966
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
|
|
967
|
+
`Falling back to structural resync.`, 'error');
|
|
968
|
+
await requestStructuralResync(bot, 'broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
|
|
969
|
+
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
970
|
+
return { executed: false, hadRotation: false, uncertain: true };
|
|
971
|
+
}
|
|
972
|
+
// 1.5. Empty/truncated-read guard: an empty snapshot is ambiguous — the
|
|
973
|
+
// account is either genuinely empty or the node is lagging behind the
|
|
974
|
+
// just-broadcast transaction. A truncated snapshot (get_full_accounts
|
|
975
|
+
// capped limit_orders; fresh creates sort last in the by_account index
|
|
976
|
+
// and are the first entries omitted) is equally ambiguous: the batch's
|
|
977
|
+
// creates may simply be missing from the returned window. Treating every
|
|
978
|
+
// pending broadcast as discarded would clear the pending-broadcast
|
|
979
|
+
// protection and let the next cycle re-CREATE slots whose orders may
|
|
980
|
+
// actually be on chain (duplicate orders). Keep the protection and let
|
|
981
|
+
// the structural resync adopt any landed orders.
|
|
982
|
+
if (pending.length > 0 && (chainSnapshot.length === 0 || chainReadTruncated)) {
|
|
983
|
+
return await deferUncertainBroadcastRead(bot, `${chainSnapshot.length === 0 ? 'Empty' : 'Truncated'} chain read for ${pending.length} pending broadcast(s)`, '(node may be lagging or the result set capped; no discard decisions made)', 'uncertain broadcast — empty/truncated chain read', { batchId: err?.batchId || null, truncated: chainReadTruncated });
|
|
984
|
+
}
|
|
985
|
+
// 2-3a. Match pending broadcasts to the chain: slot probe, fingerprint
|
|
986
|
+
// second pass, and a TOCTOU re-read for discarded CREATEs (see
|
|
987
|
+
// matchPendingToChain). A deferred re-read returns its result directly.
|
|
988
|
+
const matched = await matchPendingToChain(bot, pending, chainSnapshot, opContexts, accountRef, err);
|
|
989
|
+
if (matched.deferred)
|
|
990
|
+
return matched.deferred;
|
|
991
|
+
const { adopted, discarded } = matched;
|
|
992
|
+
// 3b. Apply decisions
|
|
993
|
+
const adoptedCount = await adoptMatchedEntries(bot, adopted, opContexts);
|
|
994
|
+
let discardedCount = await restoreDiscardedCreates(bot, discarded, opContexts);
|
|
799
995
|
// 5. Log structured summary
|
|
800
996
|
const elapsed = Date.now() - startedAt;
|
|
801
997
|
bot.manager.logger.log(`[COW][UNCERTAIN] Reconciled: ${adoptedCount} adopted, ${discardedCount} discarded ` +
|
|
@@ -815,27 +1011,7 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
815
1011
|
// 7. Request structural resync if any chain orders remain unaccounted for
|
|
816
1012
|
// after the reconciliation, ensuring the next cycle re-plans from a clean
|
|
817
1013
|
// chain snapshot.
|
|
818
|
-
|
|
819
|
-
if (!alreadyScheduled && chainSnapshot.length > 0) {
|
|
820
|
-
const reconciledOrderIds = new Set(adopted.map((a) => a.match?.id).filter(Boolean));
|
|
821
|
-
const unreconciledCount = chainSnapshot.filter((o) => !reconciledOrderIds.has(o.id)).length;
|
|
822
|
-
if (unreconciledCount > 0) {
|
|
823
|
-
bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
|
|
824
|
-
`These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
|
|
825
|
-
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
826
|
-
await bot.manager.requestStructuralGridResync('unreconciled orders after uncertain broadcast', {
|
|
827
|
-
batchId: err?.batchId || null,
|
|
828
|
-
pendingCount: pending.length,
|
|
829
|
-
adoptedCount,
|
|
830
|
-
discardedCount,
|
|
831
|
-
unreconciledCount
|
|
832
|
-
});
|
|
833
|
-
}
|
|
834
|
-
else {
|
|
835
|
-
bot._warn?.('[COW][UNCERTAIN] requestStructuralGridResync unavailable; cannot schedule structural resync.');
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
}
|
|
1014
|
+
await resyncIfUnreconciled(bot, err, pending, chainSnapshot, adopted, adoptedCount, discardedCount);
|
|
839
1015
|
return { executed: false, hadRotation: false, uncertain: true, adoptedCount, discardedCount };
|
|
840
1016
|
}
|
|
841
1017
|
/**
|
|
@@ -856,9 +1032,7 @@ async function autoCancelOneUnmatchedOrphan(bot) {
|
|
|
856
1032
|
bot._autoCancelOrphanCycleMarker = cycleId;
|
|
857
1033
|
bot._autoCancelOrphanSubCount = 0;
|
|
858
1034
|
}
|
|
859
|
-
const pending = (bot
|
|
860
|
-
? bot.manager._pendingBroadcasts.size
|
|
861
|
-
: 0;
|
|
1035
|
+
const pending = countPendingBroadcasts(bot);
|
|
862
1036
|
if (pending > 0) {
|
|
863
1037
|
return { cancelled: false, reason: 'pending-broadcasts-active' };
|
|
864
1038
|
}
|
|
@@ -955,14 +1129,7 @@ function verifyCreateAbsent(bot, freshChain, ctx) {
|
|
|
955
1129
|
sell: ctx.finalInts.sell,
|
|
956
1130
|
receive: ctx.finalInts.receive,
|
|
957
1131
|
orderType: ctx.order.type,
|
|
958
|
-
fingerprint:
|
|
959
|
-
side: ctx.order.type,
|
|
960
|
-
assetA: bot.manager?.assets?.assetA?.id,
|
|
961
|
-
assetB: bot.manager?.assets?.assetB?.id,
|
|
962
|
-
sellInt: ctx.finalInts.sell,
|
|
963
|
-
receiveInt: ctx.finalInts.receive,
|
|
964
|
-
slotId: ctx.order.id
|
|
965
|
-
})
|
|
1132
|
+
fingerprint: createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
|
|
966
1133
|
});
|
|
967
1134
|
return match ? 'landed' : 'absent';
|
|
968
1135
|
}
|
|
@@ -1043,7 +1210,11 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
|
|
|
1043
1210
|
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
1044
1211
|
const freshRead = await chainOrders.readOpenOrdersWithMeta(accountRef);
|
|
1045
1212
|
const freshChain = freshRead.orders;
|
|
1046
|
-
|
|
1213
|
+
// A truncated read (get_full_accounts caps limit_orders, and
|
|
1214
|
+
// fresh creates sort last in the by_account index) omits the
|
|
1215
|
+
// very orders this batch may have landed — 'absent' is not
|
|
1216
|
+
// authoritative here, degrade to 'unknown' and defer.
|
|
1217
|
+
if (isAuthoritativeChainRead(freshRead)) {
|
|
1047
1218
|
absence = 'absent';
|
|
1048
1219
|
for (const ctx of opContexts) {
|
|
1049
1220
|
if (!ctx)
|
|
@@ -1055,13 +1226,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
|
|
|
1055
1226
|
}
|
|
1056
1227
|
}
|
|
1057
1228
|
}
|
|
1058
|
-
// A truncated read (get_full_accounts caps limit_orders, and
|
|
1059
|
-
// fresh creates sort last in the by_account index) omits the
|
|
1060
|
-
// very orders this batch may have landed — 'absent' is not
|
|
1061
|
-
// authoritative here, degrade to 'unknown' and defer.
|
|
1062
|
-
if (freshRead.truncated && absence === 'absent') {
|
|
1063
|
-
absence = 'unknown';
|
|
1064
|
-
}
|
|
1065
1229
|
}
|
|
1066
1230
|
catch (verifyErr) {
|
|
1067
1231
|
bot.manager.logger.log(`[COW] Pre-retry chain verification failed (non-fatal): ${verifyErr?.message || verifyErr}`, 'warn');
|
|
@@ -1095,31 +1259,6 @@ async function executeWithRetryOnUncertain(bot, operations, opContexts) {
|
|
|
1095
1259
|
}
|
|
1096
1260
|
}
|
|
1097
1261
|
}
|
|
1098
|
-
/**
|
|
1099
|
-
* Whether a chain order still matches the cached pre-update state the
|
|
1100
|
-
* limit_order_update delta was built from. Only a provably-unchanged order
|
|
1101
|
-
* makes a re-broadcast of the identical delta safe (it applies to the same
|
|
1102
|
-
* base). Any other state (target applied, filled, resized) must defer.
|
|
1103
|
-
* @param {Object} chainOrder - Raw chain order object (get_full_accounts)
|
|
1104
|
-
* @param {Object|null} cachedRaw - The rawOnChain cache captured at build time
|
|
1105
|
-
* @returns {boolean}
|
|
1106
|
-
*/
|
|
1107
|
-
function chainOrderUnchangedFromCache(chainOrder, cachedRaw) {
|
|
1108
|
-
if (!chainOrder || !cachedRaw)
|
|
1109
|
-
return false;
|
|
1110
|
-
const base = chainOrder.sell_price?.base;
|
|
1111
|
-
const quote = chainOrder.sell_price?.quote;
|
|
1112
|
-
const cachedBase = cachedRaw.sell_price?.base?.amount;
|
|
1113
|
-
const cachedQuote = cachedRaw.sell_price?.quote?.amount;
|
|
1114
|
-
const cachedForSale = cachedRaw.for_sale;
|
|
1115
|
-
if (base === undefined || quote === undefined)
|
|
1116
|
-
return false;
|
|
1117
|
-
if (cachedForSale === undefined || cachedBase === undefined || cachedQuote === undefined)
|
|
1118
|
-
return false;
|
|
1119
|
-
return String(base.amount ?? '') === String(cachedBase)
|
|
1120
|
-
&& String(quote.amount ?? '') === String(cachedQuote)
|
|
1121
|
-
&& String(chainOrder.for_sale ?? '') === String(cachedForSale);
|
|
1122
|
-
}
|
|
1123
1262
|
/**
|
|
1124
1263
|
* Summarize a partial (non-atomic) broadcast for the batch-failure catch log.
|
|
1125
1264
|
* Supports both the pair-mode grouped path (groupsBroadcast/groupsTotal) and
|
|
@@ -1357,7 +1496,6 @@ function validateOperationFunds(bot, operations, assetA, assetB) {
|
|
|
1357
1496
|
if (!operations || operations.length === 0) {
|
|
1358
1497
|
return { isValid: true, summary: 'No operations to validate' };
|
|
1359
1498
|
}
|
|
1360
|
-
const { blockchainToFloat, floatToBlockchainInt, quantizeFloat } = require('./order/utils/math');
|
|
1361
1499
|
let snap = bot.manager?.getChainFundsSnapshot?.();
|
|
1362
1500
|
if (!snap) {
|
|
1363
1501
|
snap = { chainFreeSell: 0, chainFreeBuy: 0 };
|
|
@@ -1852,12 +1990,21 @@ function buildCowResultFromPlan(bot, plan) {
|
|
|
1852
1990
|
});
|
|
1853
1991
|
}
|
|
1854
1992
|
}
|
|
1993
|
+
// Refill-slot wire (boundary-hold): producers attach the slot ids of
|
|
1994
|
+
// hole-CREATEs that justify this plan's boundary shift; the executor
|
|
1995
|
+
// holds the committed boundary when a listed refill is guard-skipped.
|
|
1996
|
+
// Absent/non-array => undefined (guarded default at execution, never
|
|
1997
|
+
// fail-open). Spread-correction plans never set this (disjoint bypass).
|
|
1998
|
+
const refillSlotIds = Array.isArray(plan?.refillSlotIds)
|
|
1999
|
+
? plan.refillSlotIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
2000
|
+
: undefined;
|
|
1855
2001
|
return {
|
|
1856
2002
|
workingGrid,
|
|
1857
2003
|
workingIndexes: workingGrid.getIndexes(),
|
|
1858
2004
|
workingBoundary,
|
|
1859
2005
|
actions,
|
|
1860
|
-
origin: plan?.origin
|
|
2006
|
+
origin: plan?.origin,
|
|
2007
|
+
...(refillSlotIds !== undefined ? { refillSlotIds } : {})
|
|
1861
2008
|
};
|
|
1862
2009
|
}
|
|
1863
2010
|
/**
|
|
@@ -1935,10 +2082,14 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
|
|
|
1935
2082
|
// Only CREATE operations can be confirmed by polling (they appear as new orders on chain)
|
|
1936
2083
|
const createContexts = opContexts.filter((ctx) => ctx && ctx.kind === 'create' && ctx.finalInts && ctx.order);
|
|
1937
2084
|
if (createContexts.length === 0) {
|
|
1938
|
-
return { allConfirmed: false, confirmed: [], unconfirmed: [...opContexts] };
|
|
2085
|
+
return { allConfirmed: false, confirmed: [], unconfirmed: [...opContexts], confirmedChainIds: [] };
|
|
1939
2086
|
}
|
|
1940
2087
|
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
1941
2088
|
let remaining = [...createContexts];
|
|
2089
|
+
// Chain ids of the poll-matched fresh creates. Retained (not just
|
|
2090
|
+
// logged) so the poll-confirmed adoption path can re-read them BY ID —
|
|
2091
|
+
// the window fallback cannot prove a lagging node has them yet.
|
|
2092
|
+
const matchedChainIds = [];
|
|
1942
2093
|
for (let attempt = 1; attempt <= maxPollRetries; attempt++) {
|
|
1943
2094
|
try {
|
|
1944
2095
|
const chainRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
|
|
@@ -1966,18 +2117,13 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
|
|
|
1966
2117
|
receive: ctx.finalInts.receive,
|
|
1967
2118
|
orderType: ctx.order.type,
|
|
1968
2119
|
fingerprint: createContexts.length > 0
|
|
1969
|
-
?
|
|
1970
|
-
side: ctx.order.type,
|
|
1971
|
-
assetA: bot.manager?.assets?.assetA?.id,
|
|
1972
|
-
assetB: bot.manager?.assets?.assetB?.id,
|
|
1973
|
-
sellInt: ctx.finalInts.sell,
|
|
1974
|
-
receiveInt: ctx.finalInts.receive,
|
|
1975
|
-
slotId: ctx.order.id
|
|
1976
|
-
})
|
|
2120
|
+
? createOpFingerprintForSlot(bot, ctx.order, ctx.finalInts, ctx.order.id)
|
|
1977
2121
|
: undefined
|
|
1978
2122
|
});
|
|
1979
2123
|
if (match) {
|
|
1980
2124
|
bot.manager.logger.log(`[COW][POLL] Confirmed CREATE for slot ${ctx.order.id} on chain as ${match.id}`, 'debug');
|
|
2125
|
+
if (match.id && /^1\.7\.\d+$/.test(String(match.id)))
|
|
2126
|
+
matchedChainIds.push(String(match.id));
|
|
1981
2127
|
}
|
|
1982
2128
|
else {
|
|
1983
2129
|
stillUnconfirmed.push(ctx);
|
|
@@ -1986,7 +2132,7 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
|
|
|
1986
2132
|
if (stillUnconfirmed.length === 0) {
|
|
1987
2133
|
const confirmed = createContexts;
|
|
1988
2134
|
bot.manager.logger.log(`[COW][POLL] All ${confirmed.length} CREATE(s) confirmed on chain after ${attempt} poll(s)`, 'info');
|
|
1989
|
-
return { allConfirmed: true, confirmed, unconfirmed: [] };
|
|
2135
|
+
return { allConfirmed: true, confirmed, unconfirmed: [], confirmedChainIds: [...matchedChainIds] };
|
|
1990
2136
|
}
|
|
1991
2137
|
remaining = stillUnconfirmed;
|
|
1992
2138
|
if (attempt < maxPollRetries) {
|
|
@@ -2003,7 +2149,56 @@ async function pollChainForConfirmation(bot, opContexts, options = {}) {
|
|
|
2003
2149
|
const confirmed = createContexts.filter((ctx) => !remaining.includes(ctx));
|
|
2004
2150
|
bot.manager.logger.log(`[COW][POLL] ${confirmed.length}/${createContexts.length} CREATE(s) confirmed after ${maxPollRetries} polls; ` +
|
|
2005
2151
|
`${remaining.length} unconfirmed. Falling back to reconciliation.`, 'warn');
|
|
2006
|
-
return { allConfirmed: false, confirmed, unconfirmed: remaining };
|
|
2152
|
+
return { allConfirmed: false, confirmed, unconfirmed: remaining, confirmedChainIds: [...matchedChainIds] };
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Normalize a producer-supplied refillSlotIds wire into a Set.
|
|
2156
|
+
* Absent/empty/non-array => empty (guarded default, never fail-open).
|
|
2157
|
+
*/
|
|
2158
|
+
function toRefillSlotIdSet(refillSlotIds) {
|
|
2159
|
+
const set = new Set();
|
|
2160
|
+
if (Array.isArray(refillSlotIds)) {
|
|
2161
|
+
for (const id of refillSlotIds) {
|
|
2162
|
+
if (typeof id === 'string' && id.length > 0)
|
|
2163
|
+
set.add(id);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return set;
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Boundary-hold decision for guard-skipped refills.
|
|
2170
|
+
* When a skipped slot is one of the plan's refill slots — a hole-CREATE that
|
|
2171
|
+
* justified the planned boundary shift — the committed boundary is kept: the
|
|
2172
|
+
* slot was never placed (CREATE skip) or restored to master (UPDATE skip), so
|
|
2173
|
+
* committing the planned boundary would strand empty rail holes past it
|
|
2174
|
+
* (91->94 with 91-94 empty self-legalizes via resolveGapBand).
|
|
2175
|
+
* Unrelated vetoes (skip ids outside the refill set) never pin geometry.
|
|
2176
|
+
* The grid still commits; only the boundary value is held (same discipline
|
|
2177
|
+
* as the overrun-hold in validateBoundaryCommit).
|
|
2178
|
+
*/
|
|
2179
|
+
function resolveRefillBoundaryHold(workingBoundary, committedBoundary, skippedUpdateSlotIds, clampedUpdateSlotIds, refillSlotIds, skippedCreateSlotIds = undefined) {
|
|
2180
|
+
const refills = toRefillSlotIdSet(refillSlotIds);
|
|
2181
|
+
const heldRefillSlotIds = [];
|
|
2182
|
+
if (refills.size > 0) {
|
|
2183
|
+
const seen = new Set();
|
|
2184
|
+
const skipCollections = skippedCreateSlotIds !== undefined
|
|
2185
|
+
? [skippedUpdateSlotIds, clampedUpdateSlotIds, skippedCreateSlotIds]
|
|
2186
|
+
: [skippedUpdateSlotIds, clampedUpdateSlotIds];
|
|
2187
|
+
for (const coll of skipCollections) {
|
|
2188
|
+
if (!coll || typeof coll[Symbol.iterator] !== 'function')
|
|
2189
|
+
continue;
|
|
2190
|
+
for (const id of coll) {
|
|
2191
|
+
if (typeof id === 'string' && refills.has(id) && !seen.has(id)) {
|
|
2192
|
+
seen.add(id);
|
|
2193
|
+
heldRefillSlotIds.push(id);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
return {
|
|
2199
|
+
effectiveBoundary: heldRefillSlotIds.length > 0 ? committedBoundary : workingBoundary,
|
|
2200
|
+
heldRefillSlotIds
|
|
2201
|
+
};
|
|
2007
2202
|
}
|
|
2008
2203
|
/**
|
|
2009
2204
|
* Restore skipped update slots in the working grid to master state.
|
|
@@ -2186,87 +2381,6 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
|
|
|
2186
2381
|
* @param {Object} [options={}] - Internal execution options (replanDepth)
|
|
2187
2382
|
* @returns {Promise<Object>}
|
|
2188
2383
|
*/
|
|
2189
|
-
/**
|
|
2190
|
-
* PRE-BROADCAST CROSSED-BOOK ASSERT (defense-in-depth, any-writer detection).
|
|
2191
|
-
*
|
|
2192
|
-
* Simulates the post-batch book: currently placed master orders plus this
|
|
2193
|
-
* batch's action overlay (CREATEs add, CANCELs remove, UPDATEs reprice/move).
|
|
2194
|
-
* Returns a detail string when a planned BUY would price at-or-above a planned
|
|
2195
|
-
* SELL — a state no honest planner produces — so the caller can refuse the
|
|
2196
|
-
* broadcast instead of paying for adverse fills. Placed order prices are
|
|
2197
|
-
* independent of grid geometry, so this catches boundary overruns regardless
|
|
2198
|
-
* of which writer produced them.
|
|
2199
|
-
*
|
|
2200
|
-
* Detector only: any internal failure returns null (never blocks a broadcast).
|
|
2201
|
-
*/
|
|
2202
|
-
function detectCrossedBookPlan(manager, actions) {
|
|
2203
|
-
try {
|
|
2204
|
-
const startPrice = Number(manager?.config?.startPrice);
|
|
2205
|
-
const book = new Map();
|
|
2206
|
-
for (const o of Array.from(manager?.orders?.values?.() ?? [])) {
|
|
2207
|
-
if (!o || !o.orderId || o.price == null)
|
|
2208
|
-
continue;
|
|
2209
|
-
const price = Number(o.price);
|
|
2210
|
-
if (!Number.isFinite(price))
|
|
2211
|
-
continue;
|
|
2212
|
-
let type = o.type;
|
|
2213
|
-
if (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL) {
|
|
2214
|
-
// Legacy SPREAD-typed placed order: derive side from the same
|
|
2215
|
-
// price-vs-startPrice convention used across the codebase.
|
|
2216
|
-
if (!Number.isFinite(startPrice))
|
|
2217
|
-
continue;
|
|
2218
|
-
type = price < startPrice ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
|
|
2219
|
-
}
|
|
2220
|
-
book.set(String(o.id), { type, price });
|
|
2221
|
-
}
|
|
2222
|
-
for (const a of actions ?? []) {
|
|
2223
|
-
const id = String(a.id ?? a.orderId ?? '');
|
|
2224
|
-
if (a.type === COW_ACTIONS.CANCEL) {
|
|
2225
|
-
if (id)
|
|
2226
|
-
book.delete(id);
|
|
2227
|
-
}
|
|
2228
|
-
else if (a.type === COW_ACTIONS.UPDATE) {
|
|
2229
|
-
const newPrice = Number(a.newPrice ?? a.order?.price);
|
|
2230
|
-
const newType = a.order?.type;
|
|
2231
|
-
if (id && Number.isFinite(newPrice)) {
|
|
2232
|
-
const entry = book.get(id);
|
|
2233
|
-
const type = (newType === ORDER_TYPES.BUY || newType === ORDER_TYPES.SELL)
|
|
2234
|
-
? newType
|
|
2235
|
-
: entry?.type;
|
|
2236
|
-
if (entry)
|
|
2237
|
-
book.delete(id);
|
|
2238
|
-
const key = String(a.newGridId ?? id);
|
|
2239
|
-
if (type === ORDER_TYPES.BUY || type === ORDER_TYPES.SELL) {
|
|
2240
|
-
book.set(key, { type, price: newPrice });
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
else if (a.type === COW_ACTIONS.CREATE) {
|
|
2245
|
-
const price = Number(a.order?.price);
|
|
2246
|
-
const type = a.order?.type;
|
|
2247
|
-
if (!Number.isFinite(price) || (type !== ORDER_TYPES.BUY && type !== ORDER_TYPES.SELL))
|
|
2248
|
-
continue;
|
|
2249
|
-
if (id)
|
|
2250
|
-
book.set(id, { type, price });
|
|
2251
|
-
}
|
|
2252
|
-
}
|
|
2253
|
-
let maxBuy = -Infinity;
|
|
2254
|
-
let minSell = Infinity;
|
|
2255
|
-
for (const { type, price } of book.values()) {
|
|
2256
|
-
if (type === ORDER_TYPES.BUY && price > maxBuy)
|
|
2257
|
-
maxBuy = price;
|
|
2258
|
-
else if (type === ORDER_TYPES.SELL && price < minSell)
|
|
2259
|
-
minSell = price;
|
|
2260
|
-
}
|
|
2261
|
-
if (Number.isFinite(maxBuy) && Number.isFinite(minSell) && minSell <= maxBuy) {
|
|
2262
|
-
return `bestPlacedBuy=${maxBuy} >= bestPlacedSell=${minSell}`;
|
|
2263
|
-
}
|
|
2264
|
-
return null;
|
|
2265
|
-
}
|
|
2266
|
-
catch {
|
|
2267
|
-
return null;
|
|
2268
|
-
}
|
|
2269
|
-
}
|
|
2270
2384
|
/**
|
|
2271
2385
|
* Derive an update action's planned target size (shared by the rotation and
|
|
2272
2386
|
* plain size-update op builders).
|
|
@@ -2309,58 +2423,22 @@ function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
|
|
|
2309
2423
|
}
|
|
2310
2424
|
return target;
|
|
2311
2425
|
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
}
|
|
2329
|
-
// Single-flight COW broadcast guard (entry check): never broadcast two
|
|
2330
|
-
// batches concurrently. Overlapping broadcasts plan from the same base
|
|
2331
|
-
// grid version; when the first commits it bumps _gridVersion, so the
|
|
2332
|
-
// second's commit is refused (base-version mismatch) -> adopt-from-chain
|
|
2333
|
-
// -> snapshot reload that can drop the adopted order and produce an
|
|
2334
|
-
// orphan fill. This entry wait is an optimization; the authoritative
|
|
2335
|
-
// atomic check-and-set happens right before the broadcast below.
|
|
2336
|
-
if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
|
|
2337
|
-
popPushedWorkingGrid(bot, cowResult);
|
|
2338
|
-
return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
|
|
2339
|
-
}
|
|
2340
|
-
// DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
|
|
2341
|
-
// Cancel-only corrections (duplicate-price orphans) queued by an earlier
|
|
2342
|
-
// sync must not sit while batches run back-to-back (startup create
|
|
2343
|
-
// groups, fill bursts) — with the open-orders sync loop disabled they
|
|
2344
|
-
// would otherwise linger indefinitely, keep blocking same-level CREATEs,
|
|
2345
|
-
// and risk cancelling the wrong side of a duplicate later. Draining here
|
|
2346
|
-
// also keeps this batch's collision checks (chain_orphan_collision)
|
|
2347
|
-
// honest: orphaned chain orders already queued for cancellation are
|
|
2348
|
-
// resolved before the plan validates its CREATE targets against them.
|
|
2349
|
-
const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
|
|
2350
|
-
? bot.manager.ordersNeedingPriceCorrection.length
|
|
2351
|
-
: 0;
|
|
2352
|
-
if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
|
|
2353
|
-
try {
|
|
2354
|
-
bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
|
|
2355
|
-
const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
|
|
2356
|
-
if (drainResult?.failed > 0) {
|
|
2357
|
-
bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
catch (drainErr) {
|
|
2361
|
-
bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
|
|
2362
|
-
}
|
|
2363
|
-
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Pre-broadcast guard chain for a COW batch: create-slot validation (with
|
|
2428
|
+
* tolerance-violation filtering), recovery-exhausted block, pending-broadcast
|
|
2429
|
+
* and unmatched-chain-order guards (with adoption sync), and the crossed-book
|
|
2430
|
+
* gate. Every refusal pops the pushed working grid before returning.
|
|
2431
|
+
*
|
|
2432
|
+
* Mutates cowResult.actions in place (tolerance-violating CREATEs are
|
|
2433
|
+
* filtered); the caller holds the same array reference.
|
|
2434
|
+
*
|
|
2435
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
2436
|
+
* @param {Object} cowResult - Rebalance/COW result carrying actions
|
|
2437
|
+
* @returns {Promise<Object>} { proceed:false, result } on refusal, or
|
|
2438
|
+
* { proceed:true, crossingCandidates, intraBatchCandidates } on pass
|
|
2439
|
+
*/
|
|
2440
|
+
async function runPreBroadcastGuards(bot, cowResult) {
|
|
2441
|
+
const { actions } = cowResult;
|
|
2364
2442
|
const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
|
|
2365
2443
|
? bot.manager._lastUnmatchedChainOrders
|
|
2366
2444
|
: [];
|
|
@@ -2378,6 +2456,12 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2378
2456
|
case 'chain_orphan_collision':
|
|
2379
2457
|
reason = `unmatched on-chain order ${violation.currentOrderId} at same price`;
|
|
2380
2458
|
break;
|
|
2459
|
+
case 'same_batch_price_duplicate':
|
|
2460
|
+
reason = `another CREATE in the same batch at same broadcast price (duplicate of ${violation.duplicateOf})`;
|
|
2461
|
+
break;
|
|
2462
|
+
case 'create_price_invalid':
|
|
2463
|
+
reason = `broadcast price is not a finite number`;
|
|
2464
|
+
break;
|
|
2381
2465
|
default:
|
|
2382
2466
|
reason = `existing orderId=${violation.currentOrderId}`;
|
|
2383
2467
|
}
|
|
@@ -2396,11 +2480,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2396
2480
|
if (hasHardOccupiedViolation) {
|
|
2397
2481
|
popPushedWorkingGrid(bot, cowResult);
|
|
2398
2482
|
return {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2483
|
+
proceed: false,
|
|
2484
|
+
result: {
|
|
2485
|
+
executed: false,
|
|
2486
|
+
aborted: true,
|
|
2487
|
+
reason: 'CREATE_SLOT_OCCUPIED',
|
|
2488
|
+
violations: createSlotValidation.violations,
|
|
2489
|
+
hadRotation: false
|
|
2490
|
+
}
|
|
2404
2491
|
};
|
|
2405
2492
|
}
|
|
2406
2493
|
const violatingIds = createSlotValidation.violatingTargetIds;
|
|
@@ -2422,7 +2509,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2422
2509
|
// returns: a pushed working grid must be popped here or the
|
|
2423
2510
|
// caller would leak the stack entry.
|
|
2424
2511
|
popPushedWorkingGrid(bot, cowResult);
|
|
2425
|
-
return { executed: false, hadRotation: false };
|
|
2512
|
+
return { proceed: false, result: { executed: false, hadRotation: false } };
|
|
2426
2513
|
}
|
|
2427
2514
|
}
|
|
2428
2515
|
}
|
|
@@ -2434,27 +2521,24 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2434
2521
|
`Waiting for next fill or sync cycle to reset recovery state.`, 'warn');
|
|
2435
2522
|
popPushedWorkingGrid(bot, cowResult);
|
|
2436
2523
|
return {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2524
|
+
proceed: false,
|
|
2525
|
+
result: {
|
|
2526
|
+
executed: false,
|
|
2527
|
+
aborted: true,
|
|
2528
|
+
reason: 'RECOVERY_EXHAUSTED',
|
|
2529
|
+
hadRotation: false
|
|
2530
|
+
}
|
|
2441
2531
|
};
|
|
2442
2532
|
}
|
|
2443
2533
|
const unmatchedChainOrders = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
|
|
2444
2534
|
? bot.manager._lastUnmatchedChainOrders
|
|
2445
2535
|
: [];
|
|
2446
|
-
const pendingBroadcasts = (bot
|
|
2447
|
-
? Array.from(bot.manager._pendingBroadcasts.values())
|
|
2448
|
-
: [];
|
|
2536
|
+
const pendingBroadcasts = getPendingBroadcasts(bot);
|
|
2449
2537
|
if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
|
|
2450
2538
|
if (pendingBroadcasts.length > 0) {
|
|
2451
2539
|
bot.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
|
|
2452
2540
|
`broadcast. Running recovery before placing replacement orders.`, 'error');
|
|
2453
|
-
|
|
2454
|
-
if (bot.manager._recoveryState)
|
|
2455
|
-
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
2456
|
-
await bot.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
|
|
2457
|
-
}
|
|
2541
|
+
await requestStructuralResync(bot, 'pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map((p) => p.slotId) });
|
|
2458
2542
|
try {
|
|
2459
2543
|
await reconcileAfterUncertainBroadcast(bot, new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
|
|
2460
2544
|
operations: pendingBroadcasts.map((p) => p.order),
|
|
@@ -2469,10 +2553,13 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2469
2553
|
}
|
|
2470
2554
|
popPushedWorkingGrid(bot, cowResult);
|
|
2471
2555
|
return {
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2556
|
+
proceed: false,
|
|
2557
|
+
result: {
|
|
2558
|
+
executed: false,
|
|
2559
|
+
aborted: true,
|
|
2560
|
+
reason: 'PENDING_BROADCASTS',
|
|
2561
|
+
hadRotation: false
|
|
2562
|
+
}
|
|
2476
2563
|
};
|
|
2477
2564
|
}
|
|
2478
2565
|
const unmatchedSample = unmatchedChainOrders
|
|
@@ -2489,8 +2576,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2489
2576
|
// freshest orders; syncing on it would virtualize live slots and
|
|
2490
2577
|
// re-create duplicates. Defer the adoption to a clean read — the
|
|
2491
2578
|
// unmatched orders keep blocking CREATEs until then.
|
|
2492
|
-
if (freshRead
|
|
2493
|
-
bot.manager.logger.log('[COW] Post-guard chain snapshot
|
|
2579
|
+
if (!isAuthoritativeChainRead(freshRead)) {
|
|
2580
|
+
bot.manager.logger.log('[COW] Post-guard chain snapshot not authoritative; skipping adoption sync (partial snapshot would virtualize live slots) — unmatched chain orders keep blocking CREATEs', 'warn');
|
|
2494
2581
|
}
|
|
2495
2582
|
else if (freshRead.orders && freshRead.orders.length > 0) {
|
|
2496
2583
|
const freshSnapshot = freshRead.orders;
|
|
@@ -2530,18 +2617,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2530
2617
|
catch (syncErr) {
|
|
2531
2618
|
bot.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
|
|
2532
2619
|
}
|
|
2533
|
-
|
|
2534
|
-
if (bot.manager._recoveryState)
|
|
2535
|
-
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
2536
|
-
await bot.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
|
|
2537
|
-
}
|
|
2620
|
+
await requestStructuralResync(bot, 'unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
|
|
2538
2621
|
bot.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
|
|
2539
2622
|
popPushedWorkingGrid(bot, cowResult);
|
|
2540
2623
|
return {
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2624
|
+
proceed: false,
|
|
2625
|
+
result: {
|
|
2626
|
+
executed: false,
|
|
2627
|
+
aborted: true,
|
|
2628
|
+
reason: 'UNMATCHED_CHAIN_ORDERS',
|
|
2629
|
+
hadRotation: false
|
|
2630
|
+
}
|
|
2545
2631
|
};
|
|
2546
2632
|
}
|
|
2547
2633
|
// Crossing-check candidate set (master + pending-broadcast + unmatched
|
|
@@ -2549,7 +2635,6 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2549
2635
|
// it reflects any sync they triggered.
|
|
2550
2636
|
const crossingCandidates = buildCrossingCandidates(bot);
|
|
2551
2637
|
const intraBatchCandidates = [];
|
|
2552
|
-
const { assetA, assetB } = bot.manager.assets;
|
|
2553
2638
|
// CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
|
|
2554
2639
|
// prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
|
|
2555
2640
|
const crossedBookDetail = detectCrossedBookPlan(bot.manager, actions);
|
|
@@ -2557,17 +2642,90 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2557
2642
|
bot.manager.logger.log(`[COW] Rejecting batch pre-broadcast: crossed book detected (${crossedBookDetail})`, 'error');
|
|
2558
2643
|
popPushedWorkingGrid(bot, cowResult);
|
|
2559
2644
|
return {
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2645
|
+
proceed: false,
|
|
2646
|
+
result: {
|
|
2647
|
+
executed: false,
|
|
2648
|
+
aborted: true,
|
|
2649
|
+
reason: 'CROSSED_BOOK',
|
|
2650
|
+
detail: crossedBookDetail,
|
|
2651
|
+
hadRotation: false
|
|
2652
|
+
}
|
|
2565
2653
|
};
|
|
2566
2654
|
}
|
|
2655
|
+
return { proceed: true, crossingCandidates, intraBatchCandidates };
|
|
2656
|
+
}
|
|
2657
|
+
async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
2658
|
+
const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
|
|
2659
|
+
bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
|
|
2660
|
+
const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
|
|
2661
|
+
// Boundary-hold value: computed pre-broadcast after the skip-restore and
|
|
2662
|
+
// frozen for every downstream commit path (success + uncertain-catch).
|
|
2663
|
+
// workingBoundary itself stays untouched (audit trail).
|
|
2664
|
+
let effectiveBoundary = workingBoundary;
|
|
2665
|
+
if (bot.config.dryRun) {
|
|
2666
|
+
const cancelCount = actions.filter((a) => a.type === COW_ACTIONS.CANCEL).length;
|
|
2667
|
+
const createCount = actions.filter((a) => a.type === COW_ACTIONS.CREATE).length;
|
|
2668
|
+
const updateCount = actions.filter((a) => a.type === COW_ACTIONS.UPDATE).length;
|
|
2669
|
+
if (cancelCount > 0)
|
|
2670
|
+
bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
|
|
2671
|
+
if (createCount > 0)
|
|
2672
|
+
bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
|
|
2673
|
+
if (updateCount > 0)
|
|
2674
|
+
bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
|
|
2675
|
+
popPushedWorkingGrid(bot, cowResult);
|
|
2676
|
+
return { executed: true, hadRotation: false };
|
|
2677
|
+
}
|
|
2678
|
+
// Single-flight COW broadcast guard (entry check): never broadcast two
|
|
2679
|
+
// batches concurrently. Overlapping broadcasts plan from the same base
|
|
2680
|
+
// grid version; when the first commits it bumps _gridVersion, so the
|
|
2681
|
+
// second's commit is refused (base-version mismatch) -> adopt-from-chain
|
|
2682
|
+
// -> snapshot reload that can drop the adopted order and produce an
|
|
2683
|
+
// orphan fill. This entry wait is an optimization; the authoritative
|
|
2684
|
+
// atomic check-and-set happens right before the broadcast below.
|
|
2685
|
+
if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
|
|
2686
|
+
popPushedWorkingGrid(bot, cowResult);
|
|
2687
|
+
return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
|
|
2688
|
+
}
|
|
2689
|
+
// DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
|
|
2690
|
+
// Cancel-only corrections (duplicate-price orphans) queued by an earlier
|
|
2691
|
+
// sync must not sit while batches run back-to-back (startup create
|
|
2692
|
+
// groups, fill bursts) — with the open-orders sync loop disabled they
|
|
2693
|
+
// would otherwise linger indefinitely, keep blocking same-level CREATEs,
|
|
2694
|
+
// and risk cancelling the wrong side of a duplicate later. Draining here
|
|
2695
|
+
// also keeps this batch's collision checks (chain_orphan_collision)
|
|
2696
|
+
// honest: orphaned chain orders already queued for cancellation are
|
|
2697
|
+
// resolved before the plan validates its CREATE targets against them.
|
|
2698
|
+
const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
|
|
2699
|
+
? bot.manager.ordersNeedingPriceCorrection.length
|
|
2700
|
+
: 0;
|
|
2701
|
+
if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
|
|
2702
|
+
try {
|
|
2703
|
+
bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
|
|
2704
|
+
const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
|
|
2705
|
+
if (drainResult?.failed > 0) {
|
|
2706
|
+
bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
catch (drainErr) {
|
|
2710
|
+
bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
// Pre-broadcast guard chain (create-slot validation, recovery-exhausted
|
|
2714
|
+
// block, pending/unmatched guards, crossed-book gate) — see
|
|
2715
|
+
// runPreBroadcastGuards. Refusals already popped the working grid.
|
|
2716
|
+
const guards = await runPreBroadcastGuards(bot, cowResult);
|
|
2717
|
+
if (!guards.proceed)
|
|
2718
|
+
return guards.result;
|
|
2719
|
+
const { crossingCandidates, intraBatchCandidates } = guards;
|
|
2720
|
+
const { assetA, assetB } = bot.manager.assets;
|
|
2567
2721
|
const operations = [];
|
|
2568
2722
|
const opContexts = [];
|
|
2569
2723
|
const skippedUpdateSlotIds = new Set();
|
|
2570
2724
|
let skippedUpdateCount = 0;
|
|
2725
|
+
// Guard-skipped CREATE slot ids (hole-refills never placed). Fed to the
|
|
2726
|
+
// boundary-hold intersect alongside the UPDATE sets above — a skipped
|
|
2727
|
+
// refill CREATE strands its rail hole exactly like a restored UPDATE.
|
|
2728
|
+
const skippedCreateSlotIds = new Set();
|
|
2571
2729
|
// Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
|
|
2572
2730
|
// would spam big batches, so the guard emits one batch summary instead
|
|
2573
2731
|
// (see the summary after the action loop below).
|
|
@@ -2643,6 +2801,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2643
2801
|
const sizeValidation = validateOrderSizeForExecution(bot, order.size, order.type, order, order.size);
|
|
2644
2802
|
if (!sizeValidation.isValid) {
|
|
2645
2803
|
bot.manager.logger.log(`Skipping create op for ${action.id}: ${sizeValidation.reason}`, 'warn');
|
|
2804
|
+
if (action.id)
|
|
2805
|
+
skippedCreateSlotIds.add(action.id);
|
|
2646
2806
|
continue;
|
|
2647
2807
|
}
|
|
2648
2808
|
const liveSlot = bot.manager.orders.get(order.id);
|
|
@@ -2667,6 +2827,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2667
2827
|
`same-batch CREATE ${batchCollision.id} already at ` +
|
|
2668
2828
|
`price ${Format.formatPrice6(batchCollision.order.price)}. ` +
|
|
2669
2829
|
`The next reconcile cycle will resolve the mismatch.`, 'warn');
|
|
2830
|
+
if (order.id)
|
|
2831
|
+
skippedCreateSlotIds.add(order.id);
|
|
2670
2832
|
continue;
|
|
2671
2833
|
}
|
|
2672
2834
|
// CROSSING-PLACEMENT GUARD (create variant): the batch-level
|
|
@@ -2678,16 +2840,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2678
2840
|
// an opposite-side order cancelled in a later chunk would
|
|
2679
2841
|
// otherwise coexist with this create mid-broadcast and
|
|
2680
2842
|
// self-trade (production incident class).
|
|
2681
|
-
const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) =>
|
|
2682
|
-
const oid = o?.orderId || o?.chainOrderId;
|
|
2683
|
-
return o && oid && !cancelOpIndexByOrderId.has(oid);
|
|
2684
|
-
});
|
|
2843
|
+
const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
|
|
2685
2844
|
const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
|
|
2686
2845
|
const effectiveCrossed = createCrossed || intraBatchCrossed;
|
|
2687
2846
|
if (effectiveCrossed) {
|
|
2688
2847
|
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
|
|
2689
2848
|
`${Format.formatPrice6(createPrice)}: crosses live ` +
|
|
2690
2849
|
`${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
|
|
2850
|
+
if (order.id)
|
|
2851
|
+
skippedCreateSlotIds.add(order.id);
|
|
2691
2852
|
continue;
|
|
2692
2853
|
}
|
|
2693
2854
|
// LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
|
|
@@ -2700,20 +2861,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2700
2861
|
const isCorrectionCreate = actionOrigin === 'spread-correction'
|
|
2701
2862
|
|| (actionOrigin == null && batchOrigin === 'spread-correction');
|
|
2702
2863
|
if (!isCorrectionCreate) {
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
}
|
|
2707
|
-
catch { /* best-effort */ }
|
|
2708
|
-
const lastPrice = bot.manager?._lastFilledPrice;
|
|
2709
|
-
const lastType = bot.manager?._lastFilledType;
|
|
2710
|
-
const inc = resolveLastFillGuardIncrement(bot);
|
|
2711
|
-
const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
|
|
2712
|
-
lastFillGuardStats.checked++;
|
|
2864
|
+
const { check, refreshed } = runLastFillGuardCheck(bot, createPrice, order.size, order.type, lastFillGuardStats);
|
|
2865
|
+
if (refreshed)
|
|
2866
|
+
lastFillGuardPivotRefreshed = true;
|
|
2713
2867
|
if (check.blocked) {
|
|
2714
2868
|
lastFillGuardStats.skipped++;
|
|
2715
2869
|
const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
2716
2870
|
bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping ${order.type} CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} (halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)}); re-planned after market moves`, 'warn');
|
|
2871
|
+
if (order.id)
|
|
2872
|
+
skippedCreateSlotIds.add(order.id);
|
|
2717
2873
|
continue;
|
|
2718
2874
|
}
|
|
2719
2875
|
lastFillGuardStats.passed++;
|
|
@@ -2727,6 +2883,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2727
2883
|
const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
|
|
2728
2884
|
if (!buildResult) {
|
|
2729
2885
|
bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
|
|
2886
|
+
if (action.id)
|
|
2887
|
+
skippedCreateSlotIds.add(action.id);
|
|
2730
2888
|
continue;
|
|
2731
2889
|
}
|
|
2732
2890
|
operations.push(buildResult.op);
|
|
@@ -2795,13 +2953,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2795
2953
|
// fatal fund assert). Skipping is safe: the slot keeps its
|
|
2796
2954
|
// old commitment and the next plan re-evaluates once
|
|
2797
2955
|
// the crossed order's cancel confirms.
|
|
2798
|
-
const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) =>
|
|
2799
|
-
const oid = o?.orderId || o?.chainOrderId;
|
|
2800
|
-
return o
|
|
2801
|
-
&& oid
|
|
2802
|
-
&& oid !== action.orderId
|
|
2803
|
-
&& !cancelOpIndexByOrderId.has(oid);
|
|
2804
|
-
});
|
|
2956
|
+
const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, action.orderId, cancelOpIndexByOrderId));
|
|
2805
2957
|
if (crossedOrder) {
|
|
2806
2958
|
skippedUpdateCount++;
|
|
2807
2959
|
if (action.id)
|
|
@@ -2855,6 +3007,32 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2855
3007
|
`boundary ${bot.manager?.boundaryIdx}/gap ${bot.manager?._gapSlots} — re-proving live`, 'warn');
|
|
2856
3008
|
}
|
|
2857
3009
|
}
|
|
3010
|
+
if (stampUsable) {
|
|
3011
|
+
// Stamped-size re-proof: the stamp proved the
|
|
3012
|
+
// plan-time size, but an unprocessed fill can
|
|
3013
|
+
// land between plan-build and execution and
|
|
3014
|
+
// shrink the booked remaining below the
|
|
3015
|
+
// planned size (the PARTIAL-only growth guard
|
|
3016
|
+
// above misses it when the slot is not yet
|
|
3017
|
+
// PARTIAL). Re-prove against the LIVE master
|
|
3018
|
+
// size; invalidating the stamp routes into
|
|
3019
|
+
// the unstamped probe below, whose
|
|
3020
|
+
// isEvacuationRotationAllowed also rejects
|
|
3021
|
+
// growth and then falls through to the
|
|
3022
|
+
// normal last-fill guard.
|
|
3023
|
+
let stampPrecision = null;
|
|
3024
|
+
try {
|
|
3025
|
+
stampPrecision = getPrecisionByOrderType(bot.manager.assets, orderType);
|
|
3026
|
+
}
|
|
3027
|
+
catch {
|
|
3028
|
+
stampPrecision = null;
|
|
3029
|
+
}
|
|
3030
|
+
if (!isEvacuationSizeStillValid(newSize, Number(masterOrder?.size), stampPrecision)) {
|
|
3031
|
+
stampUsable = false;
|
|
3032
|
+
bot.manager.logger.log(`[LAST-FILL-GUARD] Stamped evacuation for ${action.id} -> ${action.newGridId} lost its size cover: ` +
|
|
3033
|
+
`planned ${Format.formatAmount(newSize)} exceeds booked remaining ${Format.formatAmount(Number(masterOrder?.size))} — re-proving live`, 'warn');
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
2858
3036
|
if (stampUsable) {
|
|
2859
3037
|
bypassedEvacuation = true;
|
|
2860
3038
|
bot.manager.logger.log(`[LAST-FILL-GUARD] Allowing ${orderType} evacuation UPDATE for ${action.id} -> ${action.newGridId} at ${Format.formatPrice6(newPrice)}: ` +
|
|
@@ -2909,16 +3087,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2909
3087
|
lastFillGuardStats.bypassed++;
|
|
2910
3088
|
}
|
|
2911
3089
|
else {
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
}
|
|
2916
|
-
catch { /* best-effort */ }
|
|
2917
|
-
const lastPrice = bot.manager?._lastFilledPrice;
|
|
2918
|
-
const lastType = bot.manager?._lastFilledType;
|
|
2919
|
-
const inc = resolveLastFillGuardIncrement(bot);
|
|
2920
|
-
const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
|
|
2921
|
-
lastFillGuardStats.checked++;
|
|
3090
|
+
const { check, refreshed } = runLastFillGuardCheck(bot, newPrice, newSize, orderType, lastFillGuardStats);
|
|
3091
|
+
if (refreshed)
|
|
3092
|
+
lastFillGuardPivotRefreshed = true;
|
|
2922
3093
|
if (check.blocked) {
|
|
2923
3094
|
lastFillGuardStats.skipped++;
|
|
2924
3095
|
skippedUpdateCount++;
|
|
@@ -3034,10 +3205,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3034
3205
|
// replaces — no crossing of an opposite-side
|
|
3035
3206
|
// live order whose cancel is not already
|
|
3036
3207
|
// queued at an earlier op position.
|
|
3037
|
-
const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) =>
|
|
3038
|
-
const oid = o?.orderId || o?.chainOrderId;
|
|
3039
|
-
return o && oid && !cancelOpIndexByOrderId.has(oid);
|
|
3040
|
-
});
|
|
3208
|
+
const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
|
|
3041
3209
|
if (fbCrossed) {
|
|
3042
3210
|
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
|
|
3043
3211
|
`${Format.formatPrice6(fbPrice)}: new ${fbType} crosses live ` +
|
|
@@ -3049,14 +3217,9 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3049
3217
|
// so it obeys the same guard with a refreshed
|
|
3050
3218
|
// pivot — no origin bypass, same as rotations.
|
|
3051
3219
|
try {
|
|
3052
|
-
|
|
3220
|
+
const { check: fbCheck, refreshed: fbRefreshed } = runLastFillGuardCheck(bot, fbPrice, fbSize, fbType, lastFillGuardStats);
|
|
3221
|
+
if (fbRefreshed)
|
|
3053
3222
|
lastFillGuardPivotRefreshed = true;
|
|
3054
|
-
}
|
|
3055
|
-
catch { /* best-effort */ }
|
|
3056
|
-
try {
|
|
3057
|
-
const fbInc = resolveLastFillGuardIncrement(bot);
|
|
3058
|
-
const fbCheck = isLastFillGuardBlocked(fbPrice, fbSize, fbType, bot.manager?._lastFilledPrice, bot.manager?._lastFilledType, fbInc);
|
|
3059
|
-
lastFillGuardStats.checked++;
|
|
3060
3223
|
if (fbCheck.blocked) {
|
|
3061
3224
|
lastFillGuardStats.skipped++;
|
|
3062
3225
|
const fbDir = fbType === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
@@ -3144,6 +3307,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3144
3307
|
}
|
|
3145
3308
|
bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
|
|
3146
3309
|
}
|
|
3310
|
+
// BOUNDARY HOLD: skipped refill slots strand empty rail holes past the
|
|
3311
|
+
// planned boundary (commit gate skips empties, self-legalizing) — hold
|
|
3312
|
+
// the committed boundary; intersect-only, grid still commits.
|
|
3313
|
+
const refillHold = resolveRefillBoundaryHold(workingBoundary, bot.manager.boundaryIdx, skippedUpdateSlotIds, clampedUpdateSlotIds, cowResult?.refillSlotIds, skippedCreateSlotIds);
|
|
3314
|
+
effectiveBoundary = refillHold.effectiveBoundary;
|
|
3315
|
+
if (refillHold.heldRefillSlotIds.length > 0) {
|
|
3316
|
+
bot.manager.logger.log(`[COW] Boundary hold: ${refillHold.heldRefillSlotIds.length} refill slot(s) skipped ` +
|
|
3317
|
+
`(${refillHold.heldRefillSlotIds.join(', ')}) — keeping ${bot.manager.boundaryIdx} over planned ${workingBoundary}`, 'warn');
|
|
3318
|
+
}
|
|
3147
3319
|
if (operations.length === 0) {
|
|
3148
3320
|
// Pop the working grid: in the re-plan recursion the fresh plan's
|
|
3149
3321
|
// grid was pushed by performSafeRebalance, and nothing downstream
|
|
@@ -3239,14 +3411,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3239
3411
|
sell: item.ctx?.finalInts?.sell,
|
|
3240
3412
|
receive: item.ctx?.finalInts?.receive,
|
|
3241
3413
|
orderType: item.ctx?.order?.type,
|
|
3242
|
-
fingerprint:
|
|
3243
|
-
side: item.ctx?.order?.type,
|
|
3244
|
-
assetA: bot.manager?.assets?.assetA?.id,
|
|
3245
|
-
assetB: bot.manager?.assets?.assetB?.id,
|
|
3246
|
-
sellInt: item.ctx?.finalInts?.sell,
|
|
3247
|
-
receiveInt: item.ctx?.finalInts?.receive,
|
|
3248
|
-
slotId
|
|
3249
|
-
})
|
|
3414
|
+
fingerprint: createOpFingerprintForSlot(bot, item.ctx?.order, item.ctx?.finalInts, slotId)
|
|
3250
3415
|
});
|
|
3251
3416
|
if (match?.id) {
|
|
3252
3417
|
workingGrid.set(slotId, { ...slot, orderId: match.id });
|
|
@@ -3289,29 +3454,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3289
3454
|
// options.result, so a later throw in this frame (e.g.
|
|
3290
3455
|
// processBatchResults after a successful commit) cannot pop a
|
|
3291
3456
|
// second time for the same grid in the batch catch below.
|
|
3292
|
-
const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes,
|
|
3457
|
+
const commitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult });
|
|
3293
3458
|
if (!commitOk) {
|
|
3294
3459
|
// Master changed during broadcast (e.g. a fill landed and was
|
|
3295
3460
|
// processed concurrently) so the commit was refused. The batch
|
|
3296
3461
|
// is on chain; adopt the placed orders from the chain so master
|
|
3297
3462
|
// converges instead of remaining divergent until a later sync.
|
|
3298
|
-
bot
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
// cannot duplicate the placed orders, and let the structural
|
|
3304
|
-
// resync adopt them from the chain.
|
|
3305
|
-
bot.manager.logger.log('[COW] Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', 'error');
|
|
3306
|
-
await requestStructuralResync(bot, 'commit refused after broadcast (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
|
|
3307
|
-
return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
|
|
3308
|
-
}
|
|
3309
|
-
// Deduct create fees for the placed orders (mirrors
|
|
3310
|
-
// processBatchResults, which the refused path bypasses).
|
|
3311
|
-
await applyAdoptionFeeAccounting(bot, executedContexts);
|
|
3312
|
-
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
3313
|
-
await persistGridAndClearPendingBroadcasts(bot, '[COW]');
|
|
3314
|
-
return { executed: false, hadRotation: false, commitRefused: true };
|
|
3463
|
+
return await recoverRefusedCommit(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts }, executedContexts, effectiveBoundary, {
|
|
3464
|
+
failureResyncReason: 'commit refused after broadcast (chain adoption unavailable)',
|
|
3465
|
+
failureLogMessage: 'Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync',
|
|
3466
|
+
preAdoptLogMessage: 'Commit refused after broadcast; adopting placed orders from chain to keep master in sync',
|
|
3467
|
+
});
|
|
3315
3468
|
}
|
|
3316
3469
|
const batchResult = await processBatchResults(bot, result, executedContexts);
|
|
3317
3470
|
const persistResult = await bot.manager.persistGrid();
|
|
@@ -3326,10 +3479,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3326
3479
|
bot.manager.logger.log(`[COW][PERSIST-GUARD] Retry also skipped/invalid ` +
|
|
3327
3480
|
`(${retryResult.reason || 'no reason'}). Master grid in memory ` +
|
|
3328
3481
|
`is ahead of disk snapshot; structural resync requested.`, 'error');
|
|
3329
|
-
|
|
3330
|
-
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
3331
|
-
await bot.manager.requestStructuralGridResync('persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
|
|
3332
|
-
}
|
|
3482
|
+
await requestStructuralResync(bot, 'persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
|
|
3333
3483
|
}
|
|
3334
3484
|
else {
|
|
3335
3485
|
delete bot.manager._persistenceWarning;
|
|
@@ -3394,22 +3544,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3394
3544
|
// _commitWorkingGrid pops on every settle path and clears
|
|
3395
3545
|
// the push marker via options.result, so a later throw here
|
|
3396
3546
|
// must not pop again in the batch catch below.
|
|
3397
|
-
const pollCommitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes,
|
|
3547
|
+
const pollCommitOk = await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, effectiveBoundary, { skipRecalc: true, result: cowResult });
|
|
3398
3548
|
if (!pollCommitOk) {
|
|
3399
3549
|
// Master moved while polling — same recovery as the
|
|
3400
3550
|
// refused-commit path: adopt from chain, keep pending
|
|
3401
3551
|
// protection if adoption is unavailable.
|
|
3402
|
-
bot
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
}
|
|
3409
|
-
await applyAdoptionFeeAccounting(bot, opContexts);
|
|
3410
|
-
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
3411
|
-
await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
|
|
3412
|
-
return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
|
|
3552
|
+
return await recoverRefusedCommit(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds }, opContexts, effectiveBoundary, {
|
|
3553
|
+
successReturn: { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true },
|
|
3554
|
+
failureResyncReason: 'poll-confirmed commit refused (chain adoption unavailable)',
|
|
3555
|
+
failureLogMessage: 'Poll-refused commit with unavailable chain adoption; keeping pending protection pending structural resync',
|
|
3556
|
+
preAdoptLogMessage: 'Poll-confirmed commit refused; adopting placed orders from chain',
|
|
3557
|
+
});
|
|
3413
3558
|
}
|
|
3414
3559
|
// Enrich master grid with chain-assigned order IDs and amounts;
|
|
3415
3560
|
// accounting enabled so the adopted orders' capital is locked
|
|
@@ -3420,19 +3565,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3420
3565
|
// on a failed adoption would let the next cycle re-create the
|
|
3421
3566
|
// VIRTUAL slots as duplicate on-chain orders. Keep the
|
|
3422
3567
|
// protection and defer to a structural resync instead.
|
|
3423
|
-
const pollAdoptedOk = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW][UNCERTAIN]');
|
|
3424
|
-
if (!pollAdoptedOk) {
|
|
3425
|
-
bot.manager.logger.log('[COW][UNCERTAIN] Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync', 'error');
|
|
3426
|
-
await requestStructuralResync(bot, 'poll-confirmed commit (chain adoption unavailable)', { reason: 'chain-adoption-unavailable' });
|
|
3427
|
-
return { executed: false, hadRotation: false, commitRefused: false, chainAdoptionPending: true };
|
|
3428
|
-
}
|
|
3429
3568
|
// The commit happened without processBatchResults (no success
|
|
3430
3569
|
// result to extract); deduct create fees so the optimistic
|
|
3431
|
-
// balance reflects the on-chain cost
|
|
3432
|
-
|
|
3433
|
-
await
|
|
3434
|
-
|
|
3435
|
-
|
|
3570
|
+
// balance reflects the on-chain cost (shared with the
|
|
3571
|
+
// refused-commit paths via recoverRefusedCommit).
|
|
3572
|
+
return await recoverRefusedCommit(bot, chainOrders, '[COW][UNCERTAIN]', { placedContexts: opContexts, polledCreateIds: confirmation.confirmedChainIds }, opContexts, effectiveBoundary, {
|
|
3573
|
+
extraReturn: { commitRefused: false },
|
|
3574
|
+
successReturn: { executed: true, hadRotation: false, uncertainResolved: true },
|
|
3575
|
+
failureResyncReason: 'poll-confirmed commit (chain adoption unavailable)',
|
|
3576
|
+
failureLogMessage: 'Poll-confirmed commit with unavailable chain adoption; keeping pending protection pending structural resync',
|
|
3577
|
+
});
|
|
3436
3578
|
}
|
|
3437
3579
|
}
|
|
3438
3580
|
catch (pollErr) {
|
|
@@ -3486,8 +3628,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3486
3628
|
* @param {Object} [details={}] - Details passed to the resync handler
|
|
3487
3629
|
*/
|
|
3488
3630
|
async function requestStructuralResync(bot, reason, details = {}) {
|
|
3489
|
-
if (typeof bot.manager?.requestStructuralGridResync !== 'function')
|
|
3631
|
+
if (typeof bot.manager?.requestStructuralGridResync !== 'function') {
|
|
3632
|
+
bot._warn?.(`[COW] requestStructuralGridResync unavailable; cannot schedule structural resync (reason: ${reason}).`);
|
|
3490
3633
|
return;
|
|
3634
|
+
}
|
|
3491
3635
|
if (bot.manager._recoveryState) {
|
|
3492
3636
|
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
3493
3637
|
}
|
|
@@ -3507,49 +3651,6 @@ async function requestStructuralResync(bot, reason, details = {}) {
|
|
|
3507
3651
|
* @param {string} logPrefix - Log prefix for sync failure messages
|
|
3508
3652
|
* @returns {Promise<boolean>}
|
|
3509
3653
|
*/
|
|
3510
|
-
/**
|
|
3511
|
-
* Collect every on-chain order id master currently needs to converge against,
|
|
3512
|
-
* so adoption can re-read them by id (immune to the get_full_accounts window
|
|
3513
|
-
* truncation) instead of relying on a partial window read.
|
|
3514
|
-
*
|
|
3515
|
-
* Sources:
|
|
3516
|
-
* - master's own tracked order ids (existing on-chain orders);
|
|
3517
|
-
* - the batch's fresh CREATE ids extracted from the broadcast result
|
|
3518
|
-
* (operation_results[i][1] aligns positionally with placedContexts[i]).
|
|
3519
|
-
*
|
|
3520
|
-
* @param {any} mgr - bot.manager
|
|
3521
|
-
* @param {any} placedResults - broadcast result (has operation_results); null when unavailable
|
|
3522
|
-
* @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
|
|
3523
|
-
* @returns {string[]} Unique, well-formed 1.7.x order ids
|
|
3524
|
-
*/
|
|
3525
|
-
function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
|
|
3526
|
-
const masterIds = new Set();
|
|
3527
|
-
const grid = mgr && mgr.grid;
|
|
3528
|
-
if (Array.isArray(grid)) {
|
|
3529
|
-
for (const slot of grid) {
|
|
3530
|
-
if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
|
|
3531
|
-
masterIds.add(String(slot.orderId));
|
|
3532
|
-
}
|
|
3533
|
-
}
|
|
3534
|
-
}
|
|
3535
|
-
const createIds = new Set();
|
|
3536
|
-
if (placedResults && Array.isArray(placedContexts)) {
|
|
3537
|
-
const opResults = extractBatchOperationResults(placedResults);
|
|
3538
|
-
if (Array.isArray(opResults)) {
|
|
3539
|
-
for (let i = 0; i < placedContexts.length; i++) {
|
|
3540
|
-
const ctx = placedContexts[i];
|
|
3541
|
-
if (!ctx || ctx.kind !== 'create')
|
|
3542
|
-
continue;
|
|
3543
|
-
const opResult = opResults[i] && opResults[i][1];
|
|
3544
|
-
if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
|
|
3545
|
-
createIds.add(String(opResult));
|
|
3546
|
-
}
|
|
3547
|
-
}
|
|
3548
|
-
}
|
|
3549
|
-
}
|
|
3550
|
-
const all = new Set([...masterIds, ...createIds]);
|
|
3551
|
-
return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
|
|
3552
|
-
}
|
|
3553
3654
|
/**
|
|
3554
3655
|
* Converge master with the chain after a COW commit was refused (master moved
|
|
3555
3656
|
* during broadcast) or an uncertain broadcast was poll-confirmed.
|
|
@@ -3573,6 +3674,10 @@ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
|
|
|
3573
3674
|
* @param {Object} [opts]
|
|
3574
3675
|
* @param {any} [opts.placedResults] - broadcast result carrying operation_results
|
|
3575
3676
|
* @param {any[]} [opts.placedContexts] - opContexts aligned with operation_results
|
|
3677
|
+
* @param {string[]} [opts.polledCreateIds] - fresh CREATE chain ids confirmed by
|
|
3678
|
+
* the uncertain-broadcast poll (no broadcast result exists on that path);
|
|
3679
|
+
* routes the poll call sites through the by-id path with its lagging-create
|
|
3680
|
+
* retry instead of the unguarded window fallback
|
|
3576
3681
|
* @returns {Promise<boolean>} true if master was adopted from the chain
|
|
3577
3682
|
*/
|
|
3578
3683
|
/**
|
|
@@ -3605,16 +3710,20 @@ async function restoreBoundaryAfterAdoption(bot, workingBoundary) {
|
|
|
3605
3710
|
}
|
|
3606
3711
|
}
|
|
3607
3712
|
async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {}) {
|
|
3608
|
-
const { placedResults = null, placedContexts = null } = opts || {};
|
|
3713
|
+
const { placedResults = null, placedContexts = null, polledCreateIds = null } = opts || {};
|
|
3609
3714
|
try {
|
|
3610
3715
|
const mgr = bot.manager;
|
|
3611
3716
|
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
3612
|
-
// PREFERRED: re-read the exact placed/existing orders by id.
|
|
3613
|
-
//
|
|
3614
|
-
//
|
|
3615
|
-
//
|
|
3616
|
-
|
|
3617
|
-
|
|
3717
|
+
// PREFERRED: re-read the exact placed/existing orders by id. The by-id
|
|
3718
|
+
// set needs the broadcast result (freshest CREATE ids), the
|
|
3719
|
+
// poll-confirmed CREATE ids, or at minimum the contexts referencing
|
|
3720
|
+
// existing chain orders — without any id hints the set would be
|
|
3721
|
+
// incomplete and would wrongly sync master against a partial picture.
|
|
3722
|
+
const haveIdHints = Boolean(placedResults)
|
|
3723
|
+
|| (Array.isArray(placedContexts) && placedContexts.length > 0)
|
|
3724
|
+
|| (Array.isArray(polledCreateIds) && polledCreateIds.length > 0);
|
|
3725
|
+
const { all: knownIds, createIds } = haveIdHints
|
|
3726
|
+
? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, polledCreateIds)
|
|
3618
3727
|
: { all: [], createIds: [] };
|
|
3619
3728
|
if (knownIds.length > 0 && typeof chainOrders.batchReadOrders === 'function') {
|
|
3620
3729
|
// Retry/backoff (fix #6): a fresh CREATE absent from the first read
|
|
@@ -3694,8 +3803,8 @@ async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {})
|
|
|
3694
3803
|
}
|
|
3695
3804
|
// FALLBACK: window read (ambiguous when truncated).
|
|
3696
3805
|
const freshRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
|
|
3697
|
-
if (freshRead
|
|
3698
|
-
bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
|
|
3806
|
+
if (!isAuthoritativeChainRead(freshRead)) {
|
|
3807
|
+
bot.manager.logger.log(`${logPrefix} Chain read ${freshRead?.truncated ? 'TRUNCATED' : 'EMPTY'} after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
|
|
3699
3808
|
return false;
|
|
3700
3809
|
}
|
|
3701
3810
|
const freshChain = freshRead.orders;
|
|
@@ -3725,6 +3834,75 @@ async function persistGridAndClearPendingBroadcasts(bot, logPrefix) {
|
|
|
3725
3834
|
}
|
|
3726
3835
|
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
3727
3836
|
}
|
|
3837
|
+
/**
|
|
3838
|
+
* Recover from a refused commit after a COW batch broadcast.
|
|
3839
|
+
* Adopts placed orders from the chain, applies fee accounting, restores
|
|
3840
|
+
* the boundary, and persists. All three commit-refused paths
|
|
3841
|
+
* (success-path refused, poll-refused, and poll-confirmed uncertain)
|
|
3842
|
+
* share this exact sequence.
|
|
3843
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
3844
|
+
* @param {Object} chainOrders - Chain orders module
|
|
3845
|
+
* @param {string} logPrefix - Log prefix for messages
|
|
3846
|
+
* @param {Object} adoptOpts - Options passed to adoptPlacedBatchFromChain
|
|
3847
|
+
* @param {Array} contexts - Op contexts for fee accounting
|
|
3848
|
+
* @param {Object} workingBoundary - The working boundary
|
|
3849
|
+
* @param {Object} [opts={}] - Named options (object form: adjacent string
|
|
3850
|
+
* options were positional before, a transposition would compile silently).
|
|
3851
|
+
* @param {Object} [opts.extraReturn={}] - Extra fields merged into the failure return object
|
|
3852
|
+
* @param {Object|null} [opts.successReturn=null] - Return object on successful adoption
|
|
3853
|
+
* (defaults to the commit-refused shape; the poll-confirmed path passes its
|
|
3854
|
+
* executed:true shape)
|
|
3855
|
+
* @param {string} [opts.failureResyncReason='commit refused after broadcast (chain adoption unavailable)']
|
|
3856
|
+
* @param {string} [opts.failureLogMessage='Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync']
|
|
3857
|
+
* - Failure-path log body (prefixed with logPrefix); each call site passes
|
|
3858
|
+
* its original wording so log greps keep matching.
|
|
3859
|
+
* @param {string|null} [opts.preAdoptLogMessage=null] - Optional warn logged before
|
|
3860
|
+
* the adoption attempt (the success-path and poll-refused sites log one;
|
|
3861
|
+
* the poll-confirmed site never did)
|
|
3862
|
+
* @returns {Promise<Object>} The commit-refused return object
|
|
3863
|
+
*/
|
|
3864
|
+
async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, contexts, workingBoundary, opts = {}) {
|
|
3865
|
+
const { extraReturn = {}, successReturn = null, failureResyncReason = 'commit refused after broadcast (chain adoption unavailable)', failureLogMessage = 'Commit refused and chain adoption unavailable; keeping pending-broadcast protection pending structural resync', preAdoptLogMessage = null, } = opts;
|
|
3866
|
+
if (preAdoptLogMessage) {
|
|
3867
|
+
bot.manager.logger.log(`${logPrefix} ${preAdoptLogMessage}`, 'warn');
|
|
3868
|
+
}
|
|
3869
|
+
const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, adoptOpts);
|
|
3870
|
+
if (!adopted) {
|
|
3871
|
+
bot.manager.logger.log(`${logPrefix} ${failureLogMessage}`, 'error');
|
|
3872
|
+
await requestStructuralResync(bot, failureResyncReason, { reason: 'chain-adoption-unavailable' });
|
|
3873
|
+
return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true, ...extraReturn };
|
|
3874
|
+
}
|
|
3875
|
+
await applyAdoptionFeeAccounting(bot, contexts);
|
|
3876
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
3877
|
+
await persistGridAndClearPendingBroadcasts(bot, logPrefix);
|
|
3878
|
+
return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
|
|
3879
|
+
}
|
|
3880
|
+
/**
|
|
3881
|
+
* Run the last-fill guard probe: refresh the pivot from still-queued fills,
|
|
3882
|
+
* read the durable pivot, and evaluate isLastFillGuardBlocked. Consolidates
|
|
3883
|
+
* the identical probe pattern in the CREATE, UPDATE-rotation, and
|
|
3884
|
+
* CREATE-fallback guards (origin bypasses and skip logging stay at the
|
|
3885
|
+
* call sites, which differ per action kind).
|
|
3886
|
+
* @param {Object} bot
|
|
3887
|
+
* @param {number} price - Target order price
|
|
3888
|
+
* @param {number} size - Order size
|
|
3889
|
+
* @param {string} type - ORDER_TYPES.BUY/SELL
|
|
3890
|
+
* @param {Object} stats - lastFillGuardStats tracker (checked++ here)
|
|
3891
|
+
* @returns {{check: Object, refreshed: boolean}}
|
|
3892
|
+
*/
|
|
3893
|
+
function runLastFillGuardCheck(bot, price, size, type, stats) {
|
|
3894
|
+
let refreshed = false;
|
|
3895
|
+
try {
|
|
3896
|
+
refreshed = !!refreshLastFillPivotFromQueue(bot);
|
|
3897
|
+
}
|
|
3898
|
+
catch { /* best-effort */ }
|
|
3899
|
+
const lastPrice = bot.manager?._lastFilledPrice;
|
|
3900
|
+
const lastType = bot.manager?._lastFilledType;
|
|
3901
|
+
const inc = resolveLastFillGuardIncrement(bot);
|
|
3902
|
+
const check = isLastFillGuardBlocked(price, size, type, lastPrice, lastType, inc);
|
|
3903
|
+
stats.checked++;
|
|
3904
|
+
return { check, refreshed };
|
|
3905
|
+
}
|
|
3728
3906
|
/**
|
|
3729
3907
|
* Apply BTS create-fee accounting for a batch that bypassed the normal
|
|
3730
3908
|
* processBatchResults pipeline (commit refused after broadcast, or
|
|
@@ -3761,6 +3939,7 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
|
|
|
3761
3939
|
isPartialPlacement: false,
|
|
3762
3940
|
expectedType: ctx.order.type,
|
|
3763
3941
|
fee: btsFeeData?.createFee || 0,
|
|
3942
|
+
order: ctx.order ?? null,
|
|
3764
3943
|
}, 'createOrder');
|
|
3765
3944
|
}
|
|
3766
3945
|
catch (feeErr) {
|
|
@@ -3800,6 +3979,22 @@ async function applyAdoptionFeeAccounting(bot, contexts) {
|
|
|
3800
3979
|
}
|
|
3801
3980
|
}
|
|
3802
3981
|
}
|
|
3982
|
+
/**
|
|
3983
|
+
* Table-driven optimistic fee call for processBatchResults: the cancel,
|
|
3984
|
+
* size-update, and both rotation branches all invoke
|
|
3985
|
+
* updateOptimisticFreeBalance(oldOrder, newOrder, context, fee, false)
|
|
3986
|
+
* under the same accountant guard — one spelling instead of four.
|
|
3987
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
3988
|
+
* @param {Object} oldOrder
|
|
3989
|
+
* @param {Object} newOrder
|
|
3990
|
+
* @param {string} context - Fee context ('fill-cancel' | 'order-update')
|
|
3991
|
+
* @param {number} fee - BTS fee amount
|
|
3992
|
+
*/
|
|
3993
|
+
async function applyOptimisticFeeBalance(bot, oldOrder, newOrder, context, fee) {
|
|
3994
|
+
if (oldOrder && newOrder && bot.manager.accountant) {
|
|
3995
|
+
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, newOrder, context, fee || 0, false);
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3803
3998
|
/**
|
|
3804
3999
|
* Process results from batch transaction execution.
|
|
3805
4000
|
* Updates order state, synchronizes with chain, and deducts BTS fees.
|
|
@@ -3827,27 +4022,16 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3827
4022
|
bot.manager.logger.log(`Cancelled surplus order ${ctx.order.id} (${ctx.order.orderId})`, 'info');
|
|
3828
4023
|
const oldOrder = ctx.order;
|
|
3829
4024
|
const committedOrder = oldOrder?.id ? bot.manager.orders.get(oldOrder.id) : null;
|
|
3830
|
-
|
|
3831
|
-
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0, false);
|
|
3832
|
-
}
|
|
4025
|
+
await applyOptimisticFeeBalance(bot, oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0);
|
|
3833
4026
|
}
|
|
3834
4027
|
else if (ctx.kind === 'size-update') {
|
|
3835
4028
|
const oldOrder = ctx.updateInfo.partialOrder;
|
|
3836
4029
|
const ord = bot.manager.orders.get(oldOrder.id);
|
|
3837
|
-
|
|
3838
|
-
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
|
|
3839
|
-
}
|
|
4030
|
+
await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
|
|
3840
4031
|
if (ord) {
|
|
3841
4032
|
const updatedSlot = { ...ord, size: ctx.updateInfo.newSize };
|
|
3842
4033
|
if (ctx.finalInts) {
|
|
3843
|
-
updatedSlot.rawOnChain =
|
|
3844
|
-
id: ord.orderId,
|
|
3845
|
-
for_sale: String(ctx.finalInts.sell),
|
|
3846
|
-
sell_price: {
|
|
3847
|
-
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
3848
|
-
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
3849
|
-
}
|
|
3850
|
-
};
|
|
4034
|
+
updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
|
|
3851
4035
|
}
|
|
3852
4036
|
updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
|
|
3853
4037
|
}
|
|
@@ -3858,7 +4042,8 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3858
4042
|
const chainOrderId = res && res[1];
|
|
3859
4043
|
if (chainOrderId) {
|
|
3860
4044
|
await bot.manager.synchronizeWithChain({
|
|
3861
|
-
gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData?.createFee || 0
|
|
4045
|
+
gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData?.createFee || 0,
|
|
4046
|
+
order: ctx.order ?? null,
|
|
3862
4047
|
}, 'createOrder');
|
|
3863
4048
|
if (ctx.finalInts) {
|
|
3864
4049
|
const syncedOrder = bot.manager.orders.get(ctx.order.id);
|
|
@@ -3866,14 +4051,7 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3866
4051
|
updatesToApply.push({
|
|
3867
4052
|
order: {
|
|
3868
4053
|
...syncedOrder,
|
|
3869
|
-
rawOnChain:
|
|
3870
|
-
id: chainOrderId,
|
|
3871
|
-
for_sale: String(ctx.finalInts.sell),
|
|
3872
|
-
sell_price: {
|
|
3873
|
-
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
3874
|
-
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
3875
|
-
}
|
|
3876
|
-
}
|
|
4054
|
+
rawOnChain: rawOnChainFromInts(chainOrderId, ctx.finalInts)
|
|
3877
4055
|
},
|
|
3878
4056
|
context: 'post-placement-metadata'
|
|
3879
4057
|
});
|
|
@@ -3903,20 +4081,11 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3903
4081
|
const { oldOrder, newPrice, newGridId, newSize, type } = rotation;
|
|
3904
4082
|
if (!newGridId) {
|
|
3905
4083
|
const ord = bot.manager.orders.get(oldOrder.id || rotation.id);
|
|
3906
|
-
|
|
3907
|
-
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0, false);
|
|
3908
|
-
}
|
|
4084
|
+
await applyOptimisticFeeBalance(bot, oldOrder, ord, 'order-update', btsFeeData?.updateFee || 0);
|
|
3909
4085
|
if (ord) {
|
|
3910
4086
|
const updatedSlot = { ...ord, size: newSize };
|
|
3911
4087
|
if (ctx.finalInts) {
|
|
3912
|
-
updatedSlot.rawOnChain =
|
|
3913
|
-
id: ord.orderId,
|
|
3914
|
-
for_sale: String(ctx.finalInts.sell),
|
|
3915
|
-
sell_price: {
|
|
3916
|
-
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
3917
|
-
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
3918
|
-
}
|
|
3919
|
-
};
|
|
4088
|
+
updatedSlot.rawOnChain = rawOnChainFromInts(ord.orderId, ctx.finalInts);
|
|
3920
4089
|
}
|
|
3921
4090
|
updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
|
|
3922
4091
|
}
|
|
@@ -3947,18 +4116,9 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3947
4116
|
orderId: oldOrder?.orderId || slot.orderId || null
|
|
3948
4117
|
};
|
|
3949
4118
|
if (ctx.finalInts) {
|
|
3950
|
-
updatedSlot.rawOnChain =
|
|
3951
|
-
id: updatedSlot.orderId,
|
|
3952
|
-
for_sale: String(ctx.finalInts.sell),
|
|
3953
|
-
sell_price: {
|
|
3954
|
-
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
3955
|
-
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
3956
|
-
}
|
|
3957
|
-
};
|
|
3958
|
-
}
|
|
3959
|
-
if (oldOrder && updatedSlot && bot.manager.accountant) {
|
|
3960
|
-
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0, false);
|
|
4119
|
+
updatedSlot.rawOnChain = rawOnChainFromInts(updatedSlot.orderId, ctx.finalInts);
|
|
3961
4120
|
}
|
|
4121
|
+
await applyOptimisticFeeBalance(bot, oldOrder, updatedSlot, 'order-update', btsFeeData?.updateFee || 0);
|
|
3962
4122
|
if (oldOrder?.id && oldOrder.id !== newGridId) {
|
|
3963
4123
|
const currentSource = bot.manager.orders.get(oldOrder.id);
|
|
3964
4124
|
if (currentSource && currentSource.orderId) {
|
|
@@ -3985,7 +4145,10 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3985
4145
|
updateOperationCount
|
|
3986
4146
|
};
|
|
3987
4147
|
}
|
|
3988
|
-
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 };
|
|
4148
|
+
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 };
|
|
4149
|
+
// Exported for regression tests (issue #23 sibling): the uncertain-broadcast
|
|
4150
|
+
// discard path must never drop a placement silently when master lost the slot.
|
|
4151
|
+
export { restoreDiscardedCreates };
|
|
3989
4152
|
export default {
|
|
3990
4153
|
buildOutsideInPairGroupsForOrders,
|
|
3991
4154
|
buildOutsideInPairGroupsForCreateEntries,
|
|
@@ -4014,6 +4177,8 @@ export default {
|
|
|
4014
4177
|
buildActionsFromPlan,
|
|
4015
4178
|
buildCowResultFromPlan,
|
|
4016
4179
|
restoreSkippedUpdateSlotsInWorkingGrid,
|
|
4180
|
+
resolveRefillBoundaryHold,
|
|
4181
|
+
toRefillSlotIdSet,
|
|
4017
4182
|
applyRotationTransitionsToWorkingGrid,
|
|
4018
4183
|
pollChainForConfirmation,
|
|
4019
4184
|
updateOrdersOnChainBatchCOW,
|