dexbot 1.4.24 → 1.4.25
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 +39 -0
- package/README.md +1 -0
- package/analysis/README.md +49 -0
- package/analysis/ama_fitting/package.json +1 -1
- package/analysis/grid_correction_check.ts +896 -0
- package/analysis/trend_detection/package.json +1 -1
- package/claw/package.json +1 -1
- package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
- package/claw/runtimes/openclaw-plugin/package.json +1 -1
- package/claw/tests/test_claw_mcp_transport.ts +2 -2
- package/dist/analysis/grid_correction_check.d.ts +90 -0
- package/dist/analysis/grid_correction_check.d.ts.map +1 -0
- package/dist/analysis/grid_correction_check.js +861 -0
- package/dist/analysis/grid_correction_check.js.map +1 -0
- package/dist/dexbot.d.ts.map +1 -1
- package/dist/dexbot.js +62 -6
- package/dist/dexbot.js.map +1 -1
- package/dist/modules/account_bots.d.ts.map +1 -1
- package/dist/modules/account_bots.js +53 -5
- package/dist/modules/account_bots.js.map +1 -1
- package/dist/modules/account_orders.d.ts +7 -10
- package/dist/modules/account_orders.d.ts.map +1 -1
- package/dist/modules/account_orders.js +26 -2
- package/dist/modules/account_orders.js.map +1 -1
- package/dist/modules/chain_orders.d.ts.map +1 -1
- package/dist/modules/chain_orders.js +21 -2
- package/dist/modules/chain_orders.js.map +1 -1
- package/dist/modules/config.d.ts.map +1 -1
- package/dist/modules/config.js +27 -0
- package/dist/modules/config.js.map +1 -1
- package/dist/modules/constants.d.ts +14 -1
- package/dist/modules/constants.d.ts.map +1 -1
- package/dist/modules/constants.js +57 -6
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +24 -0
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +66 -12
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +22 -17
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.js +646 -128
- package/dist/modules/dexbot_cow_runtime.js.map +1 -1
- package/dist/modules/dexbot_fill_runtime.d.ts +11 -1
- package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_fill_runtime.js +108 -1
- package/dist/modules/dexbot_fill_runtime.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts +17 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +215 -26
- package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
- package/dist/modules/dexbot_startup_runtime.d.ts +1 -0
- package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_startup_runtime.js +58 -7
- package/dist/modules/dexbot_startup_runtime.js.map +1 -1
- package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
- package/dist/modules/dexbot_state_recovery.js +55 -3
- package/dist/modules/dexbot_state_recovery.js.map +1 -1
- package/dist/modules/graceful_shutdown.d.ts +12 -1
- package/dist/modules/graceful_shutdown.d.ts.map +1 -1
- package/dist/modules/graceful_shutdown.js +39 -1
- package/dist/modules/graceful_shutdown.js.map +1 -1
- package/dist/modules/key_store.d.ts.map +1 -1
- package/dist/modules/key_store.js +38 -1
- package/dist/modules/key_store.js.map +1 -1
- package/dist/modules/order/accounting.d.ts.map +1 -1
- package/dist/modules/order/accounting.js +20 -3
- package/dist/modules/order/accounting.js.map +1 -1
- package/dist/modules/order/grid.d.ts +1 -1
- package/dist/modules/order/grid.d.ts.map +1 -1
- package/dist/modules/order/grid.js +288 -31
- package/dist/modules/order/grid.js.map +1 -1
- package/dist/modules/order/grid_reconcile.d.ts +4 -2
- package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile.js +242 -222
- package/dist/modules/order/grid_reconcile.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts +11 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.js +132 -6
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
- package/dist/modules/order/manager.d.ts +68 -14
- package/dist/modules/order/manager.d.ts.map +1 -1
- package/dist/modules/order/manager.js +241 -52
- package/dist/modules/order/manager.js.map +1 -1
- package/dist/modules/order/strategy.d.ts.map +1 -1
- package/dist/modules/order/strategy.js +25 -8
- package/dist/modules/order/strategy.js.map +1 -1
- package/dist/modules/order/sync_engine.d.ts.map +1 -1
- package/dist/modules/order/sync_engine.js +261 -166
- package/dist/modules/order/sync_engine.js.map +1 -1
- package/dist/modules/order/utils/math.d.ts +47 -5
- package/dist/modules/order/utils/math.d.ts.map +1 -1
- package/dist/modules/order/utils/math.js +175 -11
- package/dist/modules/order/utils/math.js.map +1 -1
- package/dist/modules/order/utils/order.d.ts +16 -15
- package/dist/modules/order/utils/order.d.ts.map +1 -1
- package/dist/modules/order/utils/order.js +199 -32
- package/dist/modules/order/utils/order.js.map +1 -1
- package/dist/modules/order/utils/slot.d.ts +8 -0
- package/dist/modules/order/utils/slot.d.ts.map +1 -0
- package/dist/modules/order/utils/slot.js +16 -0
- package/dist/modules/order/utils/slot.js.map +1 -0
- package/dist/modules/order/utils/system.d.ts.map +1 -1
- package/dist/modules/order/utils/system.js +3 -2
- package/dist/modules/order/utils/system.js.map +1 -1
- package/dist/modules/order/utils/validate.d.ts +5 -8
- package/dist/modules/order/utils/validate.d.ts.map +1 -1
- package/dist/modules/order/utils/validate.js +64 -82
- package/dist/modules/order/utils/validate.js.map +1 -1
- package/dist/modules/order/working_grid.d.ts +5 -0
- package/dist/modules/order/working_grid.d.ts.map +1 -1
- package/dist/modules/order/working_grid.js +21 -0
- package/dist/modules/order/working_grid.js.map +1 -1
- package/dist/modules/paths.d.ts +4 -2
- package/dist/modules/paths.d.ts.map +1 -1
- package/dist/modules/paths.js +79 -24
- package/dist/modules/paths.js.map +1 -1
- package/dist/scripts/analyze-orders.d.ts.map +1 -1
- package/dist/scripts/analyze-orders.js +23 -2
- package/dist/scripts/analyze-orders.js.map +1 -1
- package/dist/scripts/generate_market_adapter_whitelist.d.ts +2 -5
- package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
- package/dist/scripts/generate_market_adapter_whitelist.js +83 -1
- package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
- package/docs/DEXBOT_COMPARISON.md +11 -11
- package/docs/EVOLUTION.md +9 -8
- package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
- package/docs/GRID_RECALCULATION.md +6 -0
- package/docs/README.md +2 -2
- package/market_adapter/README.md +10 -0
- package/package.json +2 -1
- package/scripts/README.md +4 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as chainOrdersModule from './chain_orders.js';
|
|
11
11
|
const chainOrders = chainOrdersModule;
|
|
12
|
-
const { readOpenOrdersWithMetaSafe
|
|
12
|
+
const { readOpenOrdersWithMetaSafe } = chainOrdersModule;
|
|
13
13
|
import { BroadcastUncertainError as BroadcastUncertainErrorBinding } from './dexbot_credential_client.js';
|
|
14
14
|
const BroadcastUncertainError = BroadcastUncertainErrorBinding;
|
|
15
15
|
import * as orderUtils from './order/utils/order.js';
|
|
@@ -18,7 +18,27 @@ const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationRes
|
|
|
18
18
|
import * as validate from './order/utils/validate.js';
|
|
19
19
|
const { validateCreateTargetSlots, evaluateCommit, hasExecutableActions } = validate;
|
|
20
20
|
import * as math from './order/utils/math.js';
|
|
21
|
-
const { validateOrderSize,
|
|
21
|
+
const { validateOrderSize, findCrossedOrder, priceSlotEqual } = math;
|
|
22
|
+
function hasSlotPriceCollision(items, targetPrice, precision, excludeId, predicate) {
|
|
23
|
+
for (const it of items) {
|
|
24
|
+
if (predicate && !predicate(it))
|
|
25
|
+
continue;
|
|
26
|
+
if (excludeId && (it.id === excludeId || it.orderId === excludeId))
|
|
27
|
+
continue;
|
|
28
|
+
const p = it.order ? it.order.price : it.price;
|
|
29
|
+
if (p == null)
|
|
30
|
+
continue;
|
|
31
|
+
try {
|
|
32
|
+
if (priceSlotEqual(p, targetPrice, precision))
|
|
33
|
+
return it;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
if (p === targetPrice)
|
|
37
|
+
return it;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
22
42
|
// Lazy accessor so test mocks on the math module export take effect at call time.
|
|
23
43
|
function getAssetFeesSafe(...args) { return require('./order/utils/math').getAssetFeesSafe(...args); }
|
|
24
44
|
import * as constantsModule from './constants.js';
|
|
@@ -110,91 +130,6 @@ function findMissingCreateResultContexts(operationResults, opContexts) {
|
|
|
110
130
|
}
|
|
111
131
|
return missing;
|
|
112
132
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Run an immediate chain sync after a successful CREATE broadcast returned incomplete ids.
|
|
115
|
-
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
116
|
-
* @param {string} [reason]
|
|
117
|
-
* @returns {Promise<void>}
|
|
118
|
-
*/
|
|
119
|
-
async function recoverAfterMissingCreateResults(bot, reason = 'missing create operation results') {
|
|
120
|
-
try {
|
|
121
|
-
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
122
|
-
if (!accountRef || !bot.manager || !chainOrders?.readOpenOrders) {
|
|
123
|
-
bot.manager?.logger?.log?.(`[COW] Recovery sync unavailable after ${reason}`, 'warn');
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const preRecoveryMissingCreateBlockers = Array.isArray(bot.manager._lastUnmatchedChainOrders)
|
|
127
|
-
? bot.manager._lastUnmatchedChainOrders
|
|
128
|
-
.filter((order) => order?.reason === 'missing-create-result')
|
|
129
|
-
.map((order) => ({ ...order }))
|
|
130
|
-
: [];
|
|
131
|
-
// Truncated-read guard: the freshest CREATEs sort last and are exactly
|
|
132
|
-
// the orders a partial get_full_accounts window omits — syncing would
|
|
133
|
-
// virtualize them (phantom cleanup). Defer; blockers stay registered
|
|
134
|
-
// so the COW guard retries the recovery on a clean read.
|
|
135
|
-
const openOrders = await readOpenOrdersGuarded(chainOrders, accountRef, {
|
|
136
|
-
log: (message, level) => bot.manager?.logger?.log?.(message, level),
|
|
137
|
-
label: 'COW',
|
|
138
|
-
detail: `recovery sync after ${reason}`,
|
|
139
|
-
});
|
|
140
|
-
if (openOrders === null) {
|
|
141
|
-
bot.manager?.logger?.log?.(`[COW] Deferring recovery sync after ${reason}: open-order read ambiguous (truncated); blockers retained for retry.`, 'warn');
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
const recoveryResult = await bot.manager.syncFromOpenOrders(openOrders, {
|
|
145
|
-
skipAccounting: false,
|
|
146
|
-
});
|
|
147
|
-
preserveMissingCreateBlockersAfterRecovery(bot, preRecoveryMissingCreateBlockers, recoveryResult);
|
|
148
|
-
if (typeof bot.manager.persistGrid === 'function') {
|
|
149
|
-
await bot.manager.persistGrid();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
catch (err) {
|
|
153
|
-
bot.manager?.logger?.log?.(`[COW] CRITICAL: Recovery sync failed after ${reason}: ${getErrorMessage(err)}`, 'error');
|
|
154
|
-
if (typeof bot.manager?.requestStructuralGridResync === 'function') {
|
|
155
|
-
try {
|
|
156
|
-
await bot.manager.requestStructuralGridResync(`recovery sync failed after ${reason}`, {
|
|
157
|
-
error: getErrorMessage(err)
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
catch (scheduleErr) {
|
|
161
|
-
bot.manager?.logger?.log?.(`[COW] CRITICAL: Failed to schedule structural resync after recovery failure: ${getErrorMessage(scheduleErr)}`, 'error');
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Restore unresolved missing-create blockers after recovery if sync did not adopt them.
|
|
168
|
-
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
169
|
-
* @param {Array} blockers
|
|
170
|
-
* @param {Object} recoveryResult
|
|
171
|
-
*/
|
|
172
|
-
function preserveMissingCreateBlockersAfterRecovery(bot, blockers, recoveryResult) {
|
|
173
|
-
if (!Array.isArray(blockers) || blockers.length === 0 || !bot.manager)
|
|
174
|
-
return;
|
|
175
|
-
const adoptedSlotIds = new Set((Array.isArray(recoveryResult?.updatedOrders) ? recoveryResult.updatedOrders : [])
|
|
176
|
-
.filter((order) => order?.id && order?.orderId)
|
|
177
|
-
.map((order) => order.id));
|
|
178
|
-
const unresolvedBlockers = blockers.filter((blocker) => !blocker.slotId || !adoptedSlotIds.has(blocker.slotId));
|
|
179
|
-
if (unresolvedBlockers.length === 0)
|
|
180
|
-
return;
|
|
181
|
-
const currentUnmatched = Array.isArray(bot.manager._lastUnmatchedChainOrders)
|
|
182
|
-
? bot.manager._lastUnmatchedChainOrders
|
|
183
|
-
: [];
|
|
184
|
-
const currentKeys = new Set(currentUnmatched.map((order) => `${order.reason || ''}:${order.slotId || ''}:${order.operationIndex ?? ''}`));
|
|
185
|
-
const restored = [...currentUnmatched];
|
|
186
|
-
for (const blocker of unresolvedBlockers) {
|
|
187
|
-
const key = `${blocker.reason || ''}:${blocker.slotId || ''}:${blocker.operationIndex ?? ''}`;
|
|
188
|
-
if (!currentKeys.has(key))
|
|
189
|
-
restored.push({ ...blocker });
|
|
190
|
-
}
|
|
191
|
-
if (restored.length !== currentUnmatched.length) {
|
|
192
|
-
bot.manager._lastUnmatchedChainOrders = restored;
|
|
193
|
-
bot.manager._lastUnmatchedChainOrdersAt = Date.now();
|
|
194
|
-
bot.manager.logger?.log?.(`[COW] Preserving ${restored.length - currentUnmatched.length} missing-create blocker(s) after recovery sync; ` +
|
|
195
|
-
`chain snapshot did not account for the affected slot(s).`, 'warn');
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
133
|
/**
|
|
199
134
|
* Merge missing CREATE result contexts into manager._lastUnmatchedChainOrders.
|
|
200
135
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
@@ -291,6 +226,53 @@ function clearPendingBroadcasts(pendingBroadcasts) {
|
|
|
291
226
|
pendingBroadcasts.clear();
|
|
292
227
|
}
|
|
293
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Human-readable label for an order returned by findCrossedOrder.
|
|
231
|
+
* Master orders carry id/orderId; unmatched chain orders carry
|
|
232
|
+
* chainOrderId; pending-broadcast entries carry slotId and order.
|
|
233
|
+
* @param {Object} crossed
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
function crossedOrderLabel(crossed) {
|
|
237
|
+
if (!crossed)
|
|
238
|
+
return 'unknown';
|
|
239
|
+
const id = crossed.id || crossed.chainOrderId || crossed.slotId || 'unknown';
|
|
240
|
+
const orderId = crossed.orderId || crossed.chainOrderId || 'n/a';
|
|
241
|
+
const type = crossed.type || crossed.order?.type || 'unknown';
|
|
242
|
+
const price = crossed.price ?? crossed.order?.price;
|
|
243
|
+
return `${type} ${id} (${orderId}) @${price != null ? Format.formatPrice6(price) : 'n/a'}`;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Build the candidate set for crossing-placement checks: master orders
|
|
247
|
+
* plus chain-side orders that may exist on chain but are not (yet)
|
|
248
|
+
* adopted into the master grid — pending broadcasts from earlier
|
|
249
|
+
* uncertain batches and unmatched chain orders (orphans). Without these,
|
|
250
|
+
* an UPDATE-only rotation batch can re-price across an un-adopted chain
|
|
251
|
+
* order that master-grid-only checks cannot see (the pending/unmatched
|
|
252
|
+
* batch guards fire only for CREATE batches).
|
|
253
|
+
* @param {Object} bot
|
|
254
|
+
* @returns {any[]}
|
|
255
|
+
*/
|
|
256
|
+
function buildCrossingCandidates(bot) {
|
|
257
|
+
const mgr = bot?.manager;
|
|
258
|
+
if (!mgr)
|
|
259
|
+
return [];
|
|
260
|
+
const candidates = mgr.orders instanceof Map ? [...mgr.orders.values()] : [];
|
|
261
|
+
if (mgr._pendingBroadcasts instanceof Map) {
|
|
262
|
+
for (const entry of mgr._pendingBroadcasts.values()) {
|
|
263
|
+
const o = entry?.order;
|
|
264
|
+
if (o && o.type != null && o.price != null)
|
|
265
|
+
candidates.push(o);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(mgr._lastUnmatchedChainOrders)) {
|
|
269
|
+
for (const o of mgr._lastUnmatchedChainOrders) {
|
|
270
|
+
if (o && o.type != null && o.price != null)
|
|
271
|
+
candidates.push(o);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return candidates;
|
|
275
|
+
}
|
|
294
276
|
/**
|
|
295
277
|
* Drop only the pending-broadcast entries for the given CREATE slots.
|
|
296
278
|
*
|
|
@@ -348,10 +330,24 @@ function popPushedWorkingGrid(bot, cowResult) {
|
|
|
348
330
|
* @returns {Object} Ambiguous-read reconciliation result
|
|
349
331
|
*/
|
|
350
332
|
async function deferUncertainBroadcastRead(bot, detail, suffix, resyncReason, resyncOptions = {}) {
|
|
351
|
-
bot.manager.logger.log(`[COW][UNCERTAIN] ${detail}; keeping pending-broadcast protection
|
|
352
|
-
|
|
333
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] ${detail}; keeping pending-broadcast protection ${suffix}`, 'warn');
|
|
334
|
+
// Fix #6 (docs/CONSOLIDATED_ORPHAN_FIX_SUMMARY.md §2): an ambiguous/truncated chain read is
|
|
335
|
+
// node lag, not a missing order — the broadcast already succeeded. Previously every
|
|
336
|
+
// such read requested a structural resync, piling pending broadcasts (up to 14) and
|
|
337
|
+
// forcing a resync mid-broadcast (the T-BTS 06:43Z thrash). The pending-broadcast
|
|
338
|
+
// protection already prevents double-creates on the next cycle, so we keep it and
|
|
339
|
+
// only escalate to a structural resync once per cooldown window. The next clean
|
|
340
|
+
// read adopts any landed orders without the churn.
|
|
341
|
+
const cooldownMs = bot.config?.maintenance?.uncertainReadResyncCooldownMs || 30_000;
|
|
342
|
+
const lastAt = bot._lastUncertainResyncAt || 0;
|
|
343
|
+
if (Date.now() - lastAt >= cooldownMs && typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
344
|
+
bot._lastUncertainResyncAt = Date.now();
|
|
353
345
|
await bot.manager.requestStructuralGridResync(resyncReason, resyncOptions);
|
|
354
346
|
}
|
|
347
|
+
else {
|
|
348
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Structural resync escalation suppressed (cooldown ${cooldownMs}ms) — ` +
|
|
349
|
+
`pending-broadcast protection retained; next clean read adopts landed orders.`, 'debug');
|
|
350
|
+
}
|
|
355
351
|
return { executed: false, hadRotation: false, uncertain: true, ambiguousRead: true };
|
|
356
352
|
}
|
|
357
353
|
/**
|
|
@@ -712,6 +708,11 @@ async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, _optio
|
|
|
712
708
|
size: entry.order.size,
|
|
713
709
|
price: entry.order.price,
|
|
714
710
|
state: ORDER_STATES.VIRTUAL,
|
|
711
|
+
// Durable orphan evidence: this sized VIRTUAL slot
|
|
712
|
+
// is the product of a lost CREATE broadcast result,
|
|
713
|
+
// not a normal planned slot. The loadGrid sanitizer
|
|
714
|
+
// only drops the size for flagged slots.
|
|
715
|
+
createUncertain: true,
|
|
715
716
|
// Clear any stale order identity: the broadcast
|
|
716
717
|
// MAY have landed, but the slot must look like a
|
|
717
718
|
// clean adoption target (no orderId/rawOnChain)
|
|
@@ -1440,6 +1441,43 @@ function resolveIdealSizeForValidation(_bot, orderLike, fallbackSize = null) {
|
|
|
1440
1441
|
function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
|
|
1441
1442
|
return validateOrderSize(size, type, bot.manager.assets, bot.config.gridLimits?.MIN_ORDER_SIZE_FACTOR, resolveIdealSizeForValidation(bot, orderLike, fallbackSize), bot.config.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
|
|
1442
1443
|
}
|
|
1444
|
+
/**
|
|
1445
|
+
* LAST-FILL-GUARD helper — pivot ± halfIncrement (replaces price-tolerance).
|
|
1446
|
+
* last fill @x with increment i: BUY < x*(1 - i/2/100), SELL > x*(1 + i/2/100)
|
|
1447
|
+
* e.g. x=1000, i=0.5% => BUY < 997.5, SELL > 1002.5
|
|
1448
|
+
* Cold (pivot null or lastType null) => disabled. Spread correction bypasses via cowResult.origin === 'spread-correction'.
|
|
1449
|
+
* @param {number} price - Target order price
|
|
1450
|
+
* @param {number} size - Order size (unused, kept for compat)
|
|
1451
|
+
* @param {string} type - ORDER_TYPES.BUY/SELL
|
|
1452
|
+
* @param {number|null} lastPrice - Most recent fill price
|
|
1453
|
+
* @param {string|null} lastType - Most recent fill side (BUY/SELL)
|
|
1454
|
+
* @param {number|any} incrementPercent - Grid increment percent (e.g. 0.5). If assets object passed, falls back to default.
|
|
1455
|
+
* @returns {{blocked: boolean, pivot: number|null, halfInc: number, threshold: number|null}}
|
|
1456
|
+
*/
|
|
1457
|
+
function isLastFillGuardBlocked(price, _size, type, lastPrice, lastType, incrementPercent) {
|
|
1458
|
+
const numPrice = Number(price);
|
|
1459
|
+
if (!Number.isFinite(numPrice))
|
|
1460
|
+
return { blocked: false, pivot: null };
|
|
1461
|
+
if (lastPrice == null || !Number.isFinite(Number(lastPrice)) || lastType == null)
|
|
1462
|
+
return { blocked: false, pivot: null };
|
|
1463
|
+
const pivot = Number(lastPrice);
|
|
1464
|
+
// Resolve increment: fallback to default 0.5 (also covers legacy assets-object 6th arg)
|
|
1465
|
+
let inc = Number(incrementPercent);
|
|
1466
|
+
if (!Number.isFinite(inc) || inc <= 0) {
|
|
1467
|
+
inc = Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
|
|
1468
|
+
}
|
|
1469
|
+
if (!Number.isFinite(inc) || inc <= 0)
|
|
1470
|
+
return { blocked: false, pivot: null };
|
|
1471
|
+
const halfInc = inc / 2;
|
|
1472
|
+
const halfPct = halfInc / 100;
|
|
1473
|
+
const buyThreshold = pivot * (1 - halfPct);
|
|
1474
|
+
const sellThreshold = pivot * (1 + halfPct);
|
|
1475
|
+
if (type === ORDER_TYPES.BUY && numPrice > buyThreshold)
|
|
1476
|
+
return { blocked: true, pivot, halfInc, threshold: buyThreshold };
|
|
1477
|
+
if (type === ORDER_TYPES.SELL && numPrice < sellThreshold)
|
|
1478
|
+
return { blocked: true, pivot, halfInc, threshold: sellThreshold };
|
|
1479
|
+
return { blocked: false, pivot: null, halfInc, threshold: null };
|
|
1480
|
+
}
|
|
1443
1481
|
/**
|
|
1444
1482
|
* Build COW actions array from a simple plan object.
|
|
1445
1483
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
@@ -1597,7 +1635,8 @@ function buildCowResultFromPlan(bot, plan) {
|
|
|
1597
1635
|
workingGrid,
|
|
1598
1636
|
workingIndexes: workingGrid.getIndexes(),
|
|
1599
1637
|
workingBoundary,
|
|
1600
|
-
actions
|
|
1638
|
+
actions,
|
|
1639
|
+
origin: plan?.origin
|
|
1601
1640
|
};
|
|
1602
1641
|
}
|
|
1603
1642
|
/**
|
|
@@ -1821,7 +1860,10 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
|
|
|
1821
1860
|
bot.manager._boundaryShiftBudget = bot.manager._boundaryShiftBudgetBase;
|
|
1822
1861
|
}
|
|
1823
1862
|
if (typeof bot.manager.performSafeRebalance === 'function') {
|
|
1824
|
-
|
|
1863
|
+
// skipBroadcastWait: this frame is itself inside the executor's
|
|
1864
|
+
// startBroadcasting() region — waiting on the flag we hold would
|
|
1865
|
+
// stall the re-plan for the full _awaitBroadcastIdle timeout.
|
|
1866
|
+
replanned = await bot.manager.performSafeRebalance(cowResult.fills, cowResult.excludeIds || new Set(), { skipBroadcastWait: true });
|
|
1825
1867
|
}
|
|
1826
1868
|
}
|
|
1827
1869
|
catch (replanErr) {
|
|
@@ -1883,12 +1925,19 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
|
|
|
1883
1925
|
* synchronously (before the next await) once this resolves so the
|
|
1884
1926
|
* check-and-set stays atomic and two planning batches cannot both win the
|
|
1885
1927
|
* broadcast slot.
|
|
1928
|
+
*
|
|
1929
|
+
* Returns true when the caller must abort the batch (shutdown began before or
|
|
1930
|
+
* during the wait — proceeding would broadcast with post-shutdown state),
|
|
1931
|
+
* false when the caller may proceed to claim the broadcast slot.
|
|
1886
1932
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
1887
1933
|
* @param {string} label - Stage label for the deferral log (e.g. 'entry', 'pre-broadcast')
|
|
1934
|
+
* @returns {Promise<boolean>}
|
|
1888
1935
|
*/
|
|
1889
1936
|
async function waitForCowBroadcastSingleFlight(bot, label) {
|
|
1937
|
+
if (bot._shuttingDown)
|
|
1938
|
+
return true;
|
|
1890
1939
|
if (!bot._cowBroadcastInFlight)
|
|
1891
|
-
return;
|
|
1940
|
+
return false;
|
|
1892
1941
|
bot.manager.logger.log(`[COW] ${label}: a COW broadcast is already in flight; deferring this batch until it settles (prevents overlapping-broadcast commit collision).`, 'warn');
|
|
1893
1942
|
const waitDeadline = Date.now() + SINGLE_FLIGHT_MAX_WAIT_MS;
|
|
1894
1943
|
while (bot._cowBroadcastInFlight) {
|
|
@@ -1896,9 +1945,14 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
|
|
|
1896
1945
|
break;
|
|
1897
1946
|
await sleep(250);
|
|
1898
1947
|
}
|
|
1948
|
+
if (bot._shuttingDown) {
|
|
1949
|
+
bot.manager.logger.log(`[COW] ${label}: shutdown began while waiting for the in-flight broadcast; aborting this batch.`, 'warn');
|
|
1950
|
+
return true;
|
|
1951
|
+
}
|
|
1899
1952
|
if (bot._cowBroadcastInFlight) {
|
|
1900
1953
|
bot.manager.logger.log(`[COW] ${label}: waited for in-flight broadcast but it did not settle within the cap; proceeding (commit guard + chain adoption will close divergence).`, 'warn');
|
|
1901
1954
|
}
|
|
1955
|
+
return false;
|
|
1902
1956
|
}
|
|
1903
1957
|
/**
|
|
1904
1958
|
* COW broadcast: Execute blockchain operations and commit working grid on success.
|
|
@@ -1988,6 +2042,48 @@ function detectCrossedBookPlan(manager, actions) {
|
|
|
1988
2042
|
return null;
|
|
1989
2043
|
}
|
|
1990
2044
|
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Derive an update action's planned target size (shared by the rotation and
|
|
2047
|
+
* plain size-update op builders).
|
|
2048
|
+
* @param {Object} action - COW action
|
|
2049
|
+
* @returns {number}
|
|
2050
|
+
*/
|
|
2051
|
+
function plannedUpdateSize(action) {
|
|
2052
|
+
return Number.isFinite(Number(action.newSize))
|
|
2053
|
+
? Number(action.newSize)
|
|
2054
|
+
: Number(action.order?.size || 0);
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Post-fill size invariant for COW UPDATE ops: a partially-filled order
|
|
2058
|
+
* (slot state PARTIAL — the fill is already booked into slot.size) must
|
|
2059
|
+
* never be GROWN in place by a plan update. Growing it would restore the
|
|
2060
|
+
* pre-fill size on chain while the fill accounting stays on the booked
|
|
2061
|
+
* remaining size — chain and books diverge and the fill effectively
|
|
2062
|
+
* vanishes from the bot's ledger. Clamp the target to the booked remaining
|
|
2063
|
+
* size and keep the plan's price intent; a deliberate full-size top-up must
|
|
2064
|
+
* go through a cancel+create cycle, not a silent in-place grow.
|
|
2065
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
2066
|
+
* @param {Object} masterOrder - Live master-grid slot for the action
|
|
2067
|
+
* @param {number} newSize - Plan's target size for the update op
|
|
2068
|
+
* @param {Object} action - The COW action being built
|
|
2069
|
+
* @returns {number} The (possibly clamped) target size
|
|
2070
|
+
*/
|
|
2071
|
+
function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
|
|
2072
|
+
const target = Number(newSize);
|
|
2073
|
+
if (!masterOrder || masterOrder.state !== ORDER_STATES.PARTIAL)
|
|
2074
|
+
return target;
|
|
2075
|
+
const booked = Number(masterOrder.size);
|
|
2076
|
+
if (!Number.isFinite(target) || !Number.isFinite(booked) || booked <= 0)
|
|
2077
|
+
return target;
|
|
2078
|
+
if (target > booked) {
|
|
2079
|
+
bot.manager.logger.log(`[COW] Post-fill size clamp for ${action?.id || masterOrder?.id}: slot is PARTIAL with ` +
|
|
2080
|
+
`booked remaining ${Format.formatAmount(booked)} but the plan targets ` +
|
|
2081
|
+
`${Format.formatAmount(target)} — clamping to booked remaining. ` +
|
|
2082
|
+
`A partially-filled order must not be grown in place by a COW update (fill accounting divergence).`, 'warn');
|
|
2083
|
+
return booked;
|
|
2084
|
+
}
|
|
2085
|
+
return target;
|
|
2086
|
+
}
|
|
1991
2087
|
async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
1992
2088
|
const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
|
|
1993
2089
|
bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
|
|
@@ -2012,7 +2108,34 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2012
2108
|
// -> snapshot reload that can drop the adopted order and produce an
|
|
2013
2109
|
// orphan fill. This entry wait is an optimization; the authoritative
|
|
2014
2110
|
// atomic check-and-set happens right before the broadcast below.
|
|
2015
|
-
await waitForCowBroadcastSingleFlight(bot, 'entry')
|
|
2111
|
+
if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
|
|
2112
|
+
popPushedWorkingGrid(bot, cowResult);
|
|
2113
|
+
return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
|
|
2114
|
+
}
|
|
2115
|
+
// DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
|
|
2116
|
+
// Cancel-only corrections (duplicate-price orphans) queued by an earlier
|
|
2117
|
+
// sync must not sit while batches run back-to-back (startup create
|
|
2118
|
+
// groups, fill bursts) — with the open-orders sync loop disabled they
|
|
2119
|
+
// would otherwise linger indefinitely, keep blocking same-level CREATEs,
|
|
2120
|
+
// and risk cancelling the wrong side of a duplicate later. Draining here
|
|
2121
|
+
// also keeps this batch's collision checks (chain_orphan_collision)
|
|
2122
|
+
// honest: orphaned chain orders already queued for cancellation are
|
|
2123
|
+
// resolved before the plan validates its CREATE targets against them.
|
|
2124
|
+
const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
|
|
2125
|
+
? bot.manager.ordersNeedingPriceCorrection.length
|
|
2126
|
+
: 0;
|
|
2127
|
+
if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
|
|
2128
|
+
try {
|
|
2129
|
+
bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
|
|
2130
|
+
const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
|
|
2131
|
+
if (drainResult?.failed > 0) {
|
|
2132
|
+
bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
catch (drainErr) {
|
|
2136
|
+
bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2016
2139
|
const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
|
|
2017
2140
|
? bot.manager._lastUnmatchedChainOrders
|
|
2018
2141
|
: [];
|
|
@@ -2196,6 +2319,11 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2196
2319
|
hadRotation: false
|
|
2197
2320
|
};
|
|
2198
2321
|
}
|
|
2322
|
+
// Crossing-check candidate set (master + pending-broadcast + unmatched
|
|
2323
|
+
// chain orders). Built after the batch-level pending/unmatched guards so
|
|
2324
|
+
// it reflects any sync they triggered.
|
|
2325
|
+
const crossingCandidates = buildCrossingCandidates(bot);
|
|
2326
|
+
const intraBatchCandidates = [];
|
|
2199
2327
|
const { assetA, assetB } = bot.manager.assets;
|
|
2200
2328
|
// CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
|
|
2201
2329
|
// prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
|
|
@@ -2215,6 +2343,17 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2215
2343
|
const opContexts = [];
|
|
2216
2344
|
const skippedUpdateSlotIds = new Set();
|
|
2217
2345
|
let skippedUpdateCount = 0;
|
|
2346
|
+
// Slots whose size-update op was broadcast with a post-fill-clamped
|
|
2347
|
+
// target: the working grid still holds the planned (larger) size, so the
|
|
2348
|
+
// slots are re-synced from master before commit to keep the committed
|
|
2349
|
+
// books equal to the broadcast chain amounts.
|
|
2350
|
+
const clampedUpdateSlotIds = new Set();
|
|
2351
|
+
// orderId -> operations index of its cancel op. A crossing re-pricing
|
|
2352
|
+
// update is only safe when the crossed order's cancel was already queued
|
|
2353
|
+
// at an earlier position: ops broadcast in MAX_OPS_PER_BROADCAST chunks,
|
|
2354
|
+
// so an earlier index means the cancel confirms on chain (same or earlier
|
|
2355
|
+
// chunk, applied sequentially) before the crossing order lands.
|
|
2356
|
+
const cancelOpIndexByOrderId = new Map();
|
|
2218
2357
|
const idsToLock = new Set();
|
|
2219
2358
|
for (const action of actions) {
|
|
2220
2359
|
if (action.type === COW_ACTIONS.CANCEL && action.orderId) {
|
|
@@ -2250,6 +2389,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2250
2389
|
try {
|
|
2251
2390
|
const op = await chainOrders.buildCancelOrderOp(bot.account, action.orderId);
|
|
2252
2391
|
operations.push(op);
|
|
2392
|
+
if (action.orderId)
|
|
2393
|
+
cancelOpIndexByOrderId.set(action.orderId, operations.length - 1);
|
|
2253
2394
|
const order = bot.manager.orders.get(action.id) || { id: action.id, orderId: action.orderId };
|
|
2254
2395
|
opContexts.push({ kind: 'cancel', order });
|
|
2255
2396
|
}
|
|
@@ -2286,8 +2427,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2286
2427
|
`(diff=${priceDrift}); rebuilding CREATE op with live price.`, 'debug');
|
|
2287
2428
|
}
|
|
2288
2429
|
const createPrice = effectiveOrder.price;
|
|
2289
|
-
const
|
|
2290
|
-
const batchCollision =
|
|
2430
|
+
const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
|
|
2431
|
+
const batchCollision = hasSlotPriceCollision(opContexts, createPrice, precision, order.id, (ctx) => ctx.kind === 'create' && ctx.order?.price != null);
|
|
2291
2432
|
if (batchCollision) {
|
|
2292
2433
|
bot.manager.logger.log(`[COW] Skipping CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ` +
|
|
2293
2434
|
`same-batch CREATE ${batchCollision.id} already at ` +
|
|
@@ -2295,6 +2436,43 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2295
2436
|
`The next reconcile cycle will resolve the mismatch.`, 'warn');
|
|
2296
2437
|
continue;
|
|
2297
2438
|
}
|
|
2439
|
+
// CROSSING-PLACEMENT GUARD (create variant): the batch-level
|
|
2440
|
+
// validators (validateCreateTargetSlots, detectCrossedBookPlan)
|
|
2441
|
+
// simulated the PLANNED price, but the pre-broadcast price
|
|
2442
|
+
// freshness rebuild above can move the op's price. Re-check
|
|
2443
|
+
// crossing on the FINAL price against live and chain-side
|
|
2444
|
+
// orders not already cancelled at an earlier op position —
|
|
2445
|
+
// an opposite-side order cancelled in a later chunk would
|
|
2446
|
+
// otherwise coexist with this create mid-broadcast and
|
|
2447
|
+
// self-trade (production incident class).
|
|
2448
|
+
const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => {
|
|
2449
|
+
const oid = o?.orderId || o?.chainOrderId;
|
|
2450
|
+
return o && oid && !cancelOpIndexByOrderId.has(oid);
|
|
2451
|
+
});
|
|
2452
|
+
const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
|
|
2453
|
+
const effectiveCrossed = createCrossed || intraBatchCrossed;
|
|
2454
|
+
if (effectiveCrossed) {
|
|
2455
|
+
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
|
|
2456
|
+
`${Format.formatPrice6(createPrice)}: crosses live ` +
|
|
2457
|
+
`${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
// LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
|
|
2461
|
+
// Spread correction bypasses so gap repair can close. Cold (null) => disabled.
|
|
2462
|
+
try {
|
|
2463
|
+
if (cowResult?.origin !== 'spread-correction') {
|
|
2464
|
+
const lastPrice = bot.manager?._lastFilledPrice;
|
|
2465
|
+
const lastType = bot.manager?._lastFilledType;
|
|
2466
|
+
const inc = Number(bot.manager?.config?.incrementPercent ?? bot?.config?.incrementPercent ?? constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
|
|
2467
|
+
const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
|
|
2468
|
+
if (check.blocked) {
|
|
2469
|
+
const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
2470
|
+
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');
|
|
2471
|
+
continue;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
catch (_e) { /* guard is best-effort */ }
|
|
2298
2476
|
const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
|
|
2299
2477
|
const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
|
|
2300
2478
|
if (!buildResult) {
|
|
@@ -2303,6 +2481,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2303
2481
|
}
|
|
2304
2482
|
operations.push(buildResult.op);
|
|
2305
2483
|
opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
|
|
2484
|
+
intraBatchCandidates.push(effectiveOrder);
|
|
2306
2485
|
recordPendingBroadcast(bot, {
|
|
2307
2486
|
opIndex: operations.length - 1,
|
|
2308
2487
|
ctxIndex: opContexts.length - 1,
|
|
@@ -2322,17 +2501,88 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2322
2501
|
const newPrice = Number.isFinite(Number(action.newPrice))
|
|
2323
2502
|
? Number(action.newPrice)
|
|
2324
2503
|
: Number(action.order?.price);
|
|
2325
|
-
const newSize =
|
|
2326
|
-
? Number(action.newSize)
|
|
2327
|
-
: Number(action.order?.size || 0);
|
|
2504
|
+
const newSize = plannedUpdateSize(action);
|
|
2328
2505
|
if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
|
|
2329
2506
|
continue;
|
|
2330
2507
|
}
|
|
2508
|
+
// POST-FILL GROWTH GUARD (rotation): a rotation must
|
|
2509
|
+
// not GROW a partially-filled order back above its
|
|
2510
|
+
// booked remaining size — the fill is already booked
|
|
2511
|
+
// into slot.size, and growing in place diverges the
|
|
2512
|
+
// fill accounting (chain restores the pre-fill size
|
|
2513
|
+
// while the books keep the post-fill remainder). Skip
|
|
2514
|
+
// like the guards above: the working grid restores the
|
|
2515
|
+
// slot from master and the next plan re-evaluates.
|
|
2516
|
+
if (masterOrder.state === ORDER_STATES.PARTIAL
|
|
2517
|
+
&& Number.isFinite(Number(masterOrder.size))
|
|
2518
|
+
&& Number(masterOrder.size) > 0
|
|
2519
|
+
&& newSize > Number(masterOrder.size)) {
|
|
2520
|
+
skippedUpdateCount++;
|
|
2521
|
+
if (action.id)
|
|
2522
|
+
skippedUpdateSlotIds.add(action.id);
|
|
2523
|
+
if (action.newGridId)
|
|
2524
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
2525
|
+
bot.manager.logger.log(`[COW] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
|
|
2526
|
+
`slot is PARTIAL with booked remaining ${Format.formatAmount(Number(masterOrder.size))} ` +
|
|
2527
|
+
`but the plan targets ${Format.formatAmount(newSize)} — a partially-filled order ` +
|
|
2528
|
+
`must not be grown in place (fill accounting divergence).`, 'warn');
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2331
2531
|
const rotationSizeValidation = validateOrderSizeForExecution(bot, newSize, orderType, action.order, newSize);
|
|
2332
2532
|
if (!rotationSizeValidation.isValid) {
|
|
2333
2533
|
bot.manager.logger.log(`Skipping rotation update ${action.id} -> ${action.newGridId}: ${rotationSizeValidation.reason}`, 'warn');
|
|
2334
2534
|
continue;
|
|
2335
2535
|
}
|
|
2536
|
+
// CROSSING-PLACEMENT GUARD: re-pricing an order must
|
|
2537
|
+
// never cross an opposite-side live order whose cancel
|
|
2538
|
+
// is not already queued at an earlier op position.
|
|
2539
|
+
// Re-pricing a buy upward across our own live sell
|
|
2540
|
+
// ladder self-trades during the chunked broadcast
|
|
2541
|
+
// window (production incident: a startup buy was
|
|
2542
|
+
// re-priced upward and filled against our own live
|
|
2543
|
+
// opposite-side sells that this same plan was still
|
|
2544
|
+
// cancelling in later chunks — dozens of self-fills,
|
|
2545
|
+
// fatal fund assert). Skipping is safe: the slot keeps its
|
|
2546
|
+
// old commitment and the next plan re-evaluates once
|
|
2547
|
+
// the crossed order's cancel confirms.
|
|
2548
|
+
const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => {
|
|
2549
|
+
const oid = o?.orderId || o?.chainOrderId;
|
|
2550
|
+
return o
|
|
2551
|
+
&& oid
|
|
2552
|
+
&& oid !== action.orderId
|
|
2553
|
+
&& !cancelOpIndexByOrderId.has(oid);
|
|
2554
|
+
});
|
|
2555
|
+
if (crossedOrder) {
|
|
2556
|
+
skippedUpdateCount++;
|
|
2557
|
+
if (action.id)
|
|
2558
|
+
skippedUpdateSlotIds.add(action.id);
|
|
2559
|
+
if (action.newGridId)
|
|
2560
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
2561
|
+
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
|
|
2562
|
+
`new ${orderType} @${Format.formatPrice6(newPrice)} crosses live ` +
|
|
2563
|
+
`${crossedOrderLabel(crossedOrder)}; re-planned after its cancel confirms.`, 'warn');
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2566
|
+
// LAST-FILL PRICE GUARD (UPDATE rotation): pivot ± halfIncrement — same as CREATE.
|
|
2567
|
+
try {
|
|
2568
|
+
if (cowResult?.origin !== 'spread-correction') {
|
|
2569
|
+
const lastPrice = bot.manager?._lastFilledPrice;
|
|
2570
|
+
const lastType = bot.manager?._lastFilledType;
|
|
2571
|
+
const inc = Number(bot.manager?.config?.incrementPercent ?? bot?.config?.incrementPercent ?? constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
|
|
2572
|
+
const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
|
|
2573
|
+
if (check.blocked) {
|
|
2574
|
+
skippedUpdateCount++;
|
|
2575
|
+
if (action.id)
|
|
2576
|
+
skippedUpdateSlotIds.add(action.id);
|
|
2577
|
+
if (action.newGridId)
|
|
2578
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
2579
|
+
const dir = orderType === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
2580
|
+
bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping ${orderType} UPDATE for ${action.id} -> ${action.newGridId} at ${Format.formatPrice6(newPrice)}: ${dir} last filled ${Format.formatPrice6(check.pivot)} (halfInc ${check.halfInc}% thr ${Format.formatPrice6(check.threshold)})`, 'warn');
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
catch (_e) { /* best-effort */ }
|
|
2336
2586
|
const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
|
|
2337
2587
|
const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
|
|
2338
2588
|
if (!buildResult) {
|
|
@@ -2356,12 +2606,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2356
2606
|
},
|
|
2357
2607
|
finalInts: buildResult.finalInts
|
|
2358
2608
|
});
|
|
2609
|
+
intraBatchCandidates.push({ type: orderType, price: newPrice, orderId: action.orderId, id: action.newGridId || action.id });
|
|
2359
2610
|
continue;
|
|
2360
2611
|
}
|
|
2361
|
-
const newSize = Number.isFinite(Number(action.newSize))
|
|
2362
|
-
? Number(action.newSize)
|
|
2363
|
-
: Number(action.order?.size || 0);
|
|
2364
2612
|
const masterOrder = bot.manager.orders.get(action.id);
|
|
2613
|
+
const plannedNewSize = plannedUpdateSize(action);
|
|
2614
|
+
const newSize = clampPostFillUpdateSize(bot, masterOrder, plannedNewSize, action);
|
|
2615
|
+
if (newSize !== plannedNewSize) {
|
|
2616
|
+
clampedUpdateSlotIds.add(action.id);
|
|
2617
|
+
}
|
|
2365
2618
|
const orderType = action.order?.type || masterOrder?.type;
|
|
2366
2619
|
const cachedRawOnChain = masterOrder?.rawOnChain || action.order?.rawOnChain || null;
|
|
2367
2620
|
const op = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell: newSize, orderType }, cachedRawOnChain);
|
|
@@ -2375,6 +2628,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2375
2628
|
continue;
|
|
2376
2629
|
}
|
|
2377
2630
|
operations.push(op.op);
|
|
2631
|
+
if (masterOrder?.price != null)
|
|
2632
|
+
intraBatchCandidates.push({ type: orderType, price: masterOrder.price, orderId: action.orderId, id: action.id });
|
|
2378
2633
|
const partialOrder = masterOrder || {
|
|
2379
2634
|
id: action.id,
|
|
2380
2635
|
orderId: action.orderId,
|
|
@@ -2406,20 +2661,38 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2406
2661
|
bot.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
|
|
2407
2662
|
}
|
|
2408
2663
|
else if (fbType && fbSize > 0 && fbPrice > 0) {
|
|
2409
|
-
const
|
|
2664
|
+
const fbPrecision = fbType === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
|
|
2665
|
+
const fbCollision = hasSlotPriceCollision([...bot.manager.orders.values()], fbPrice, fbPrecision, targetSlotId, isOrderPlaced);
|
|
2410
2666
|
if (fbCollision) {
|
|
2411
2667
|
bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
|
|
2412
2668
|
`existing placed order ${fbCollision.id} (${fbCollision.orderId}) ` +
|
|
2413
2669
|
`already at price ${Format.formatPrice6(fbCollision.price)}.`, 'warn');
|
|
2414
2670
|
continue;
|
|
2415
2671
|
}
|
|
2416
|
-
const fbBatchCollision =
|
|
2672
|
+
const fbBatchCollision = hasSlotPriceCollision(opContexts, fbPrice, fbPrecision, targetSlotId, (ctx) => ctx.kind === 'create');
|
|
2417
2673
|
if (fbBatchCollision) {
|
|
2418
2674
|
bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
|
|
2419
2675
|
`same-batch CREATE ${fbBatchCollision.id} already at ` +
|
|
2420
2676
|
`price ${Format.formatPrice6(fbBatchCollision.order.price)}.`, 'warn');
|
|
2421
2677
|
continue;
|
|
2422
2678
|
}
|
|
2679
|
+
// CROSSING-PLACEMENT GUARD (fallback variant):
|
|
2680
|
+
// the not-found conversion re-prices to the
|
|
2681
|
+
// rotation's target price, so it must obey the
|
|
2682
|
+
// same crossing rule as the rotation UPDATE it
|
|
2683
|
+
// replaces — no crossing of an opposite-side
|
|
2684
|
+
// live order whose cancel is not already
|
|
2685
|
+
// queued at an earlier op position.
|
|
2686
|
+
const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => {
|
|
2687
|
+
const oid = o?.orderId || o?.chainOrderId;
|
|
2688
|
+
return o && oid && !cancelOpIndexByOrderId.has(oid);
|
|
2689
|
+
});
|
|
2690
|
+
if (fbCrossed) {
|
|
2691
|
+
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
|
|
2692
|
+
`${Format.formatPrice6(fbPrice)}: new ${fbType} crosses live ` +
|
|
2693
|
+
`${crossedOrderLabel(fbCrossed)}; re-planned after its cancel confirms.`, 'warn');
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2423
2696
|
const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
|
|
2424
2697
|
const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
|
|
2425
2698
|
if (fbResult) {
|
|
@@ -2453,6 +2726,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2453
2726
|
if (skippedUpdateCount > 0) {
|
|
2454
2727
|
restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
|
|
2455
2728
|
}
|
|
2729
|
+
if (clampedUpdateSlotIds.size > 0) {
|
|
2730
|
+
const masterVersion = Number.isFinite(Number(bot.manager?._gridVersion))
|
|
2731
|
+
? Number(bot.manager._gridVersion)
|
|
2732
|
+
: undefined;
|
|
2733
|
+
for (const slotId of clampedUpdateSlotIds) {
|
|
2734
|
+
workingGrid.syncFromMaster(bot.manager.orders, slotId, masterVersion);
|
|
2735
|
+
}
|
|
2736
|
+
bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
|
|
2737
|
+
}
|
|
2456
2738
|
if (operations.length === 0) {
|
|
2457
2739
|
// Pop the working grid: in the re-plan recursion the fresh plan's
|
|
2458
2740
|
// grid was pushed by performSafeRebalance, and nothing downstream
|
|
@@ -2495,7 +2777,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2495
2777
|
// for it to settle, then claim the slot synchronously (no await
|
|
2496
2778
|
// between the check inside waitForCowBroadcastSingleFlight and the
|
|
2497
2779
|
// assignment below), so two batches can never broadcast together.
|
|
2498
|
-
await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast')
|
|
2780
|
+
if (await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast')) {
|
|
2781
|
+
popPushedWorkingGrid(bot, cowResult);
|
|
2782
|
+
return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
|
|
2783
|
+
}
|
|
2499
2784
|
bot._cowBroadcastInFlight = true;
|
|
2500
2785
|
heldBroadcastSlot = true;
|
|
2501
2786
|
await bot._ensureCredentialDaemonWritable('COW batch broadcast');
|
|
@@ -2514,19 +2799,74 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2514
2799
|
const missingSlots = missingCreateResults
|
|
2515
2800
|
.map((item) => item.ctx?.order?.id || item.ctx?.id || `op-${item.index}`)
|
|
2516
2801
|
.join(', ');
|
|
2517
|
-
bot.manager.logger.log(`[COW]
|
|
2518
|
-
`
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2802
|
+
bot.manager.logger.log(`[COW] ${missingCreateResults.length} CREATE op(s) returned no chainOrderId ` +
|
|
2803
|
+
`(${missingSlots}). Resolving from chain before commit (normalize, don't reject).`, 'warn');
|
|
2804
|
+
// Layer 2 (normalize, don't reject + uncertain-broadcast routing):
|
|
2805
|
+
// the batch otherwise succeeded, so a missing id is the
|
|
2806
|
+
// "broadcast succeeded but attach lost" ambiguous case. Poll the
|
|
2807
|
+
// chain to separate REAL on-chain orders (adopt their id into the
|
|
2808
|
+
// working grid so they commit normally) from PHANTOMS (normalize the
|
|
2809
|
+
// slot in place to a clean empty). Either way the REST of the batch
|
|
2810
|
+
// commits — we never discard a whole batch over one missing id.
|
|
2811
|
+
const confirmation = await pollChainForConfirmation(bot, missingCreateResults.map((m) => m.ctx));
|
|
2812
|
+
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
2813
|
+
let chainSnap = null;
|
|
2814
|
+
try {
|
|
2815
|
+
const cr = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
|
|
2816
|
+
if (cr && !cr.truncated)
|
|
2817
|
+
chainSnap = cr.orders;
|
|
2818
|
+
}
|
|
2819
|
+
catch { /* best-effort; confirmed set already known from poll */ }
|
|
2820
|
+
let adoptedCount = 0;
|
|
2821
|
+
let normalizedCount = 0;
|
|
2822
|
+
for (const item of missingCreateResults) {
|
|
2823
|
+
const slotId = item.ctx?.order?.id || item.ctx?.id;
|
|
2824
|
+
const slot = workingGrid.get(slotId);
|
|
2825
|
+
if (!slot)
|
|
2826
|
+
continue;
|
|
2827
|
+
const isConfirmed = confirmation.confirmed.some((c) => (c?.order?.id || c?.id) === slotId);
|
|
2828
|
+
if (isConfirmed && chainSnap) {
|
|
2829
|
+
const match = findChainOrderForSlot(bot, chainSnap, slotId, {
|
|
2830
|
+
sell: item.ctx?.finalInts?.sell,
|
|
2831
|
+
receive: item.ctx?.finalInts?.receive,
|
|
2832
|
+
orderType: item.ctx?.order?.type,
|
|
2833
|
+
fingerprint: buildCreateOpFingerprint({
|
|
2834
|
+
side: item.ctx?.order?.type,
|
|
2835
|
+
assetA: bot.manager?.assets?.assetA?.id,
|
|
2836
|
+
assetB: bot.manager?.assets?.assetB?.id,
|
|
2837
|
+
sellInt: item.ctx?.finalInts?.sell,
|
|
2838
|
+
receiveInt: item.ctx?.finalInts?.receive,
|
|
2839
|
+
slotId
|
|
2840
|
+
})
|
|
2841
|
+
});
|
|
2842
|
+
if (match?.id) {
|
|
2843
|
+
workingGrid.set(slotId, { ...slot, orderId: match.id });
|
|
2844
|
+
adoptedCount++;
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
// Normalize: drop the size so the slot cannot persist as a
|
|
2849
|
+
// phantom placed order (VIRTUAL + size>0 + no orderId) — the
|
|
2850
|
+
// exact corrupt shape that recurs as a sized orphan. It commits
|
|
2851
|
+
// as a clean empty and is re-placed by the next cycle / spread
|
|
2852
|
+
// correction.
|
|
2853
|
+
workingGrid.set(slotId, {
|
|
2854
|
+
...slot,
|
|
2855
|
+
size: 0,
|
|
2856
|
+
orderId: null,
|
|
2857
|
+
state: ORDER_STATES.VIRTUAL
|
|
2858
|
+
});
|
|
2859
|
+
normalizedCount++;
|
|
2860
|
+
}
|
|
2861
|
+
bot.manager.logger.log(`[COW] Missing CREATEs resolved: ${adoptedCount} adopted from chain, ` +
|
|
2862
|
+
`${normalizedCount} normalized in place; committing remainder of batch.`, 'warn');
|
|
2863
|
+
// NOTE: we intentionally do NOT mark these as structural blockers.
|
|
2864
|
+
// Normalizing the slot to a clean empty lets the next cycle re-create
|
|
2865
|
+
// it, and the regular open-orders sync adopts any order that really
|
|
2866
|
+
// did land on chain — so a permanent block (which would reject every
|
|
2867
|
+
// subsequent CREATE batch) is avoided.
|
|
2868
|
+
// DO NOT pop/discard the working grid — fall through to commit the
|
|
2869
|
+
// rest of the batch below.
|
|
2530
2870
|
}
|
|
2531
2871
|
// Pre-apply rotation state transitions to the working grid so the
|
|
2532
2872
|
// COW commit is truly atomic for structural changes (source → VIRTUAL,
|
|
@@ -2547,7 +2887,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2547
2887
|
// is on chain; adopt the placed orders from the chain so master
|
|
2548
2888
|
// converges instead of remaining divergent until a later sync.
|
|
2549
2889
|
bot.manager.logger.log('[COW] Commit refused after broadcast; adopting placed orders from chain to keep master in sync', 'warn');
|
|
2550
|
-
const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]');
|
|
2890
|
+
const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
|
|
2551
2891
|
if (!adopted) {
|
|
2552
2892
|
// Chain state unknown (empty/lagging read or sync failure):
|
|
2553
2893
|
// keep the pending-broadcast protection so a later plan
|
|
@@ -2560,6 +2900,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2560
2900
|
// Deduct create fees for the placed orders (mirrors
|
|
2561
2901
|
// processBatchResults, which the refused path bypasses).
|
|
2562
2902
|
await applyAdoptionFeeAccounting(bot, executedContexts);
|
|
2903
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
2563
2904
|
await persistGridAndClearPendingBroadcasts(bot, '[COW]');
|
|
2564
2905
|
return { executed: false, hadRotation: false, commitRefused: true };
|
|
2565
2906
|
}
|
|
@@ -2657,6 +2998,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2657
2998
|
return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
|
|
2658
2999
|
}
|
|
2659
3000
|
await applyAdoptionFeeAccounting(bot, opContexts);
|
|
3001
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
2660
3002
|
await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
|
|
2661
3003
|
return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
|
|
2662
3004
|
}
|
|
@@ -2679,6 +3021,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2679
3021
|
// result to extract); deduct create fees so the optimistic
|
|
2680
3022
|
// balance reflects the on-chain cost.
|
|
2681
3023
|
await applyAdoptionFeeAccounting(bot, opContexts);
|
|
3024
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
2682
3025
|
await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
|
|
2683
3026
|
return { executed: true, hadRotation: false, uncertainResolved: true };
|
|
2684
3027
|
}
|
|
@@ -2755,16 +3098,193 @@ async function requestStructuralResync(bot, reason, details = {}) {
|
|
|
2755
3098
|
* @param {string} logPrefix - Log prefix for sync failure messages
|
|
2756
3099
|
* @returns {Promise<boolean>}
|
|
2757
3100
|
*/
|
|
2758
|
-
|
|
3101
|
+
/**
|
|
3102
|
+
* Collect every on-chain order id master currently needs to converge against,
|
|
3103
|
+
* so adoption can re-read them by id (immune to the get_full_accounts window
|
|
3104
|
+
* truncation) instead of relying on a partial window read.
|
|
3105
|
+
*
|
|
3106
|
+
* Sources:
|
|
3107
|
+
* - master's own tracked order ids (existing on-chain orders);
|
|
3108
|
+
* - the batch's fresh CREATE ids extracted from the broadcast result
|
|
3109
|
+
* (operation_results[i][1] aligns positionally with placedContexts[i]).
|
|
3110
|
+
*
|
|
3111
|
+
* @param {any} mgr - bot.manager
|
|
3112
|
+
* @param {any} placedResults - broadcast result (has operation_results); null when unavailable
|
|
3113
|
+
* @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
|
|
3114
|
+
* @returns {string[]} Unique, well-formed 1.7.x order ids
|
|
3115
|
+
*/
|
|
3116
|
+
function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
|
|
3117
|
+
const masterIds = new Set();
|
|
3118
|
+
const grid = mgr && mgr.grid;
|
|
3119
|
+
if (Array.isArray(grid)) {
|
|
3120
|
+
for (const slot of grid) {
|
|
3121
|
+
if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
|
|
3122
|
+
masterIds.add(String(slot.orderId));
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
const createIds = new Set();
|
|
3127
|
+
if (placedResults && Array.isArray(placedContexts)) {
|
|
3128
|
+
const opResults = extractBatchOperationResults(placedResults);
|
|
3129
|
+
if (Array.isArray(opResults)) {
|
|
3130
|
+
for (let i = 0; i < placedContexts.length; i++) {
|
|
3131
|
+
const ctx = placedContexts[i];
|
|
3132
|
+
if (!ctx || ctx.kind !== 'create')
|
|
3133
|
+
continue;
|
|
3134
|
+
const opResult = opResults[i] && opResults[i][1];
|
|
3135
|
+
if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
|
|
3136
|
+
createIds.add(String(opResult));
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
const all = new Set([...masterIds, ...createIds]);
|
|
3142
|
+
return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Converge master with the chain after a COW commit was refused (master moved
|
|
3146
|
+
* during broadcast) or an uncertain broadcast was poll-confirmed.
|
|
3147
|
+
*
|
|
3148
|
+
* Preferred path (when the broadcast result is available): re-read the exact
|
|
3149
|
+
* on-chain orders BY ID — every id master already tracks plus the batch's
|
|
3150
|
+
* fresh CREATE ids. get_objects returns the complete, authoritative set
|
|
3151
|
+
* regardless of account size, so syncFromOpenOrders converges master instead
|
|
3152
|
+
* of dropping the freshest creates (which previously left permanent orphans
|
|
3153
|
+
* and tripped the fund invariant). This is fully immune to the
|
|
3154
|
+
* get_full_accounts window truncation that broke the old window-read path.
|
|
3155
|
+
*
|
|
3156
|
+
* Fallback (no broadcast result, e.g. uncertain paths, or by-id read
|
|
3157
|
+
* unavailable): window read. A truncated read is ambiguous — only the
|
|
3158
|
+
* freshest orders are dropped — so it MUST NOT drive adoption; we return false
|
|
3159
|
+
* and let the caller keep pending-broadcast protection + structural resync.
|
|
3160
|
+
*
|
|
3161
|
+
* @param {any} bot
|
|
3162
|
+
* @param {any} chainOrders - chain_orders module (has readOpenOrdersWithMetaSafe + batchReadOrders)
|
|
3163
|
+
* @param {string} logPrefix
|
|
3164
|
+
* @param {Object} [opts]
|
|
3165
|
+
* @param {any} [opts.placedResults] - broadcast result carrying operation_results
|
|
3166
|
+
* @param {any[]} [opts.placedContexts] - opContexts aligned with operation_results
|
|
3167
|
+
* @returns {Promise<boolean>} true if master was adopted from the chain
|
|
3168
|
+
*/
|
|
3169
|
+
/**
|
|
3170
|
+
* P1-atomic: after a refused/uncertain COW commit is recovered by adopting the
|
|
3171
|
+
* placed orders from the chain, re-apply the rotational boundary this batch had
|
|
3172
|
+
* already computed (workingBoundary) to master. The refused commit discarded
|
|
3173
|
+
* the working grid, so without this the next rebalance would re-derive the
|
|
3174
|
+
* boundary from a master that still reflects the pre-fill layout and could
|
|
3175
|
+
* re-stamp the just-filled slot x. Applying workingBoundary commits the
|
|
3176
|
+
* post-fill rotation immediately (no re-broadcast), so the next placement lands
|
|
3177
|
+
* at the shifted slot, never at x.
|
|
3178
|
+
*
|
|
3179
|
+
* @param {any} bot
|
|
3180
|
+
* @param {number} workingBoundary - boundary index the refused batch targeted
|
|
3181
|
+
*/
|
|
3182
|
+
async function restoreBoundaryAfterAdoption(bot, workingBoundary) {
|
|
3183
|
+
try {
|
|
3184
|
+
if (workingBoundary === undefined || workingBoundary === null)
|
|
3185
|
+
return;
|
|
3186
|
+
if (typeof bot.manager._restoreBoundary === 'function') {
|
|
3187
|
+
bot.manager._restoreBoundary(workingBoundary);
|
|
3188
|
+
}
|
|
3189
|
+
else {
|
|
3190
|
+
bot.manager.boundaryIdx = workingBoundary;
|
|
3191
|
+
}
|
|
3192
|
+
bot.manager.logger.log(`[COW] Restored rotational boundary ${workingBoundary} after refused/uncertain commit + chain adoption (atomic re-plan)`, 'info');
|
|
3193
|
+
}
|
|
3194
|
+
catch (e) {
|
|
3195
|
+
bot.manager.logger.log(`[COW] Post-adoption boundary restore failed: ${getErrorMessage(e)}`, 'warn');
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {}) {
|
|
3199
|
+
const { placedResults = null, placedContexts = null } = opts || {};
|
|
2759
3200
|
try {
|
|
3201
|
+
const mgr = bot.manager;
|
|
2760
3202
|
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
3203
|
+
// PREFERRED: re-read the exact placed/existing orders by id. Only when we
|
|
3204
|
+
// have the broadcast result (so the set includes the freshest CREATE ids);
|
|
3205
|
+
// without it the by-id set would be incomplete and would wrongly sync
|
|
3206
|
+
// master against a partial picture.
|
|
3207
|
+
const { all: knownIds, createIds } = placedResults
|
|
3208
|
+
? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts)
|
|
3209
|
+
: { all: [], createIds: [] };
|
|
3210
|
+
if (knownIds.length > 0 && typeof chainOrders.batchReadOrders === 'function') {
|
|
3211
|
+
// Retry/backoff (fix #6): a fresh CREATE absent from the first read
|
|
3212
|
+
// is a lagging node, not a missing order; a read error is equally
|
|
3213
|
+
// transient. Retry with backoff before deferring to the structural
|
|
3214
|
+
// resync path — deferral blocks CREATEs for minutes.
|
|
3215
|
+
const maxAttempts = Math.max(1, Number(COW_PERFORMANCE.ADOPTION_READ_MAX_ATTEMPTS) || 3);
|
|
3216
|
+
const baseBackoff = Math.max(250, Number(COW_PERFORMANCE.ADOPTION_READ_BACKOFF_MS) || 2000);
|
|
3217
|
+
let chainMap = null;
|
|
3218
|
+
let laggingCreateIds = [];
|
|
3219
|
+
let lastReadError = null;
|
|
3220
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3221
|
+
chainMap = null;
|
|
3222
|
+
laggingCreateIds = [];
|
|
3223
|
+
lastReadError = null;
|
|
3224
|
+
try {
|
|
3225
|
+
chainMap = await chainOrders.batchReadOrders(knownIds);
|
|
3226
|
+
}
|
|
3227
|
+
catch (byIdErr) {
|
|
3228
|
+
// A by-id read failure must NOT fall through to the window read
|
|
3229
|
+
// (which would also miss the freshest creates and virtualize them).
|
|
3230
|
+
lastReadError = getErrorMessage(byIdErr);
|
|
3231
|
+
}
|
|
3232
|
+
if (chainMap) {
|
|
3233
|
+
// Lagging-node guard (phantom-virtualization risk): a FRESHLY
|
|
3234
|
+
// BROADCAST create id returning null here almost certainly means the
|
|
3235
|
+
// queried node has not yet indexed the order (it was just placed), not
|
|
3236
|
+
// that it is gone. If we synced a partial set, syncFromOpenOrders'
|
|
3237
|
+
// phantom-cleanup would virtualize that live order and count it as a
|
|
3238
|
+
// fill, re-creating a duplicate on the next cycle — the exact orphan
|
|
3239
|
+
// class this path exists to prevent.
|
|
3240
|
+
for (const id of createIds) {
|
|
3241
|
+
if (chainMap.get(id) == null)
|
|
3242
|
+
laggingCreateIds.push(id);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
if (chainMap && laggingCreateIds.length === 0)
|
|
3246
|
+
break;
|
|
3247
|
+
if (attempt < maxAttempts) {
|
|
3248
|
+
const wait = baseBackoff * Math.pow(2, attempt - 1);
|
|
3249
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption read retry ${attempt}/${maxAttempts - 1} in ${wait}ms ` +
|
|
3250
|
+
`(${chainMap ? `fresh CREATE(s) absent: ${laggingCreateIds.join(', ')}` : `read failed: ${lastReadError}`})`, 'warn');
|
|
3251
|
+
await sleep(wait);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
if (!chainMap) {
|
|
3255
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption read failed after ${maxAttempts} attempt(s): ${lastReadError}; deferring (pending-broadcast protection kept)`, 'warn');
|
|
3256
|
+
return false;
|
|
3257
|
+
}
|
|
3258
|
+
if (laggingCreateIds.length > 0) {
|
|
3259
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption deferred: fresh CREATE(s) ${laggingCreateIds.join(', ')} absent from ` +
|
|
3260
|
+
`${maxAttempts} chain read(s) (likely lagging node). ` +
|
|
3261
|
+
'Keeping pending-broadcast protection pending a caught-up read.', 'error');
|
|
3262
|
+
return false;
|
|
3263
|
+
}
|
|
3264
|
+
const fullChain = [];
|
|
3265
|
+
if (chainMap && typeof chainMap.forEach === 'function') {
|
|
3266
|
+
chainMap.forEach((order) => { if (order)
|
|
3267
|
+
fullChain.push(order); });
|
|
3268
|
+
}
|
|
3269
|
+
else if (Array.isArray(chainMap)) {
|
|
3270
|
+
for (const o of chainMap)
|
|
3271
|
+
if (o)
|
|
3272
|
+
fullChain.push(o);
|
|
3273
|
+
}
|
|
3274
|
+
// Informational: any other known id (master's pre-existing orders)
|
|
3275
|
+
// absent is expected — those were cancelled/filled in this batch.
|
|
3276
|
+
if (fullChain.length < knownIds.length) {
|
|
3277
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption: ${knownIds.length - fullChain.length}/${knownIds.length} known id(s) absent ` +
|
|
3278
|
+
'(expected cancels/fills); adopting the rest', 'debug');
|
|
3279
|
+
}
|
|
3280
|
+
if (fullChain.length > 0 && typeof mgr.syncFromOpenOrders === 'function') {
|
|
3281
|
+
await mgr.syncFromOpenOrders(fullChain, { skipAccounting: false });
|
|
3282
|
+
bot.manager.logger.log(`${logPrefix} Adopted ${fullChain.length} on-chain order(s) by id after refused/uncertain commit (truncation-immune)`, 'info');
|
|
3283
|
+
return true;
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
// FALLBACK: window read (ambiguous when truncated).
|
|
2761
3287
|
const freshRead = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
|
|
2762
|
-
// A truncated read (get_full_accounts caps the limit_orders window and
|
|
2763
|
-
// fresh creates sort last) omits the very orders this batch just
|
|
2764
|
-
// broadcast — the adoption sync could not register them, and clearing
|
|
2765
|
-
// the pending-broadcast protection would let the next cycle re-create
|
|
2766
|
-
// them as duplicates on chain. Treat truncated like an unreadable
|
|
2767
|
-
// chain state: keep the protection and defer to a structural resync.
|
|
2768
3288
|
if (freshRead.truncated) {
|
|
2769
3289
|
bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
|
|
2770
3290
|
return false;
|
|
@@ -3051,14 +3571,12 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3051
3571
|
updateOperationCount
|
|
3052
3572
|
};
|
|
3053
3573
|
}
|
|
3054
|
-
export { buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts,
|
|
3574
|
+
export { isLastFillGuardBlocked, 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 };
|
|
3055
3575
|
export default {
|
|
3056
3576
|
buildOutsideInPairGroupsForOrders,
|
|
3057
3577
|
buildOutsideInPairGroupsForCreateEntries,
|
|
3058
3578
|
extractOperationResults,
|
|
3059
3579
|
findMissingCreateResultContexts,
|
|
3060
|
-
recoverAfterMissingCreateResults,
|
|
3061
|
-
preserveMissingCreateBlockersAfterRecovery,
|
|
3062
3580
|
markMissingCreateResultsAsStructuralBlocker,
|
|
3063
3581
|
formatUnmatchedChainOrderForLog,
|
|
3064
3582
|
recordPendingBroadcast,
|