dexbot 1.4.24 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +7 -2
- package/analysis/README.md +61 -5
- package/analysis/ama_fitting/package.json +1 -1
- package/analysis/analyze_derivatives.ts +1 -1
- package/analysis/bot_key_utils.ts +272 -3
- package/analysis/bot_usage/discover_bot_accounts.ts +64 -9
- package/analysis/bot_usage/kibana_bot_queries.ts +40 -1
- package/analysis/chart_utils.ts +6 -3
- package/analysis/grid_correction_check.ts +932 -0
- package/analysis/resolve_bot_accounts.ts +199 -0
- package/analysis/trade_profitability.ts +36 -8
- package/analysis/tradingview/README.md +25 -0
- package/analysis/tradingview/analyze_tradingview.ts +2 -2
- package/analysis/trend_detection/package.json +1 -1
- package/claw/modules/claw_launcher.ts +5 -4
- package/claw/modules/credit_runtime_adapter.ts +2 -1
- package/claw/modules/dexbot_profiles.ts +4 -3
- 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/analyze_derivatives.js +1 -1
- package/dist/analysis/bot_key_utils.d.ts +28 -1
- package/dist/analysis/bot_key_utils.d.ts.map +1 -1
- package/dist/analysis/bot_key_utils.js +385 -3
- package/dist/analysis/bot_key_utils.js.map +1 -1
- package/dist/analysis/bot_usage/discover_bot_accounts.js +60 -10
- package/dist/analysis/bot_usage/discover_bot_accounts.js.map +1 -1
- package/dist/analysis/bot_usage/kibana_bot_queries.d.ts +45 -1
- package/dist/analysis/bot_usage/kibana_bot_queries.d.ts.map +1 -1
- package/dist/analysis/bot_usage/kibana_bot_queries.js +39 -1
- package/dist/analysis/bot_usage/kibana_bot_queries.js.map +1 -1
- package/dist/analysis/chart_utils.d.ts +2 -1
- package/dist/analysis/chart_utils.d.ts.map +1 -1
- package/dist/analysis/chart_utils.js +4 -1
- package/dist/analysis/chart_utils.js.map +1 -1
- 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 +901 -0
- package/dist/analysis/grid_correction_check.js.map +1 -0
- package/dist/analysis/resolve_bot_accounts.d.ts +3 -0
- package/dist/analysis/resolve_bot_accounts.d.ts.map +1 -0
- package/dist/analysis/resolve_bot_accounts.js +216 -0
- package/dist/analysis/resolve_bot_accounts.js.map +1 -0
- package/dist/analysis/trade_profitability.d.ts.map +1 -1
- package/dist/analysis/trade_profitability.js +40 -8
- package/dist/analysis/trade_profitability.js.map +1 -1
- package/dist/analysis/tradingview/analyze_tradingview.js +2 -2
- package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +2 -1
- package/dist/bot.js.map +1 -1
- package/dist/dexbot.d.ts.map +1 -1
- package/dist/dexbot.js +134 -22
- package/dist/dexbot.js.map +1 -1
- package/dist/market_adapter/ama_signal_runner.js +2 -2
- package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js +2 -1
- package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts +4 -1
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.js +3 -2
- package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
- package/dist/modules/account_bots.d.ts +42 -1
- package/dist/modules/account_bots.d.ts.map +1 -1
- package/dist/modules/account_bots.js +221 -22
- 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/bitshares_client.d.ts +4 -1
- package/dist/modules/bitshares_client.d.ts.map +1 -1
- package/dist/modules/bitshares_client.js +12 -4
- package/dist/modules/bitshares_client.js.map +1 -1
- package/dist/modules/bot_settings.d.ts.map +1 -1
- package/dist/modules/bot_settings.js +2 -1
- package/dist/modules/bot_settings.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/cli_colors.d.ts +39 -0
- package/dist/modules/cli_colors.d.ts.map +1 -0
- package/dist/modules/cli_colors.js +42 -0
- package/dist/modules/cli_colors.js.map +1 -0
- 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 +66 -11
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/credit_pricing.d.ts +61 -0
- package/dist/modules/credit_pricing.d.ts.map +1 -0
- package/dist/modules/credit_pricing.js +237 -0
- package/dist/modules/credit_pricing.js.map +1 -0
- package/dist/modules/credit_runtime.d.ts +1 -0
- package/dist/modules/credit_runtime.d.ts.map +1 -1
- package/dist/modules/credit_runtime.js +49 -107
- package/dist/modules/credit_runtime.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +25 -0
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +82 -12
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +39 -17
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.js +875 -134
- 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 +29 -14
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +279 -63
- 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/fund_registry.d.ts +1 -1
- package/dist/modules/fund_registry.js +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/launcher/adapter_requirement.d.ts +38 -0
- package/dist/modules/launcher/adapter_requirement.d.ts.map +1 -0
- package/dist/modules/launcher/adapter_requirement.js +141 -0
- package/dist/modules/launcher/adapter_requirement.js.map +1 -0
- package/dist/modules/launcher/bot_supervisor.d.ts +2 -2
- package/dist/modules/launcher/bot_supervisor.d.ts.map +1 -1
- package/dist/modules/launcher/bot_supervisor.js +32 -3
- package/dist/modules/launcher/bot_supervisor.js.map +1 -1
- package/dist/modules/launcher/launch_modes.d.ts.map +1 -1
- package/dist/modules/launcher/launch_modes.js +5 -4
- package/dist/modules/launcher/launch_modes.js.map +1 -1
- package/dist/modules/launcher/monolithic_runtime.d.ts +1 -1
- package/dist/modules/launcher/monolithic_runtime.d.ts.map +1 -1
- package/dist/modules/launcher/monolithic_runtime.js +12 -8
- package/dist/modules/launcher/monolithic_runtime.js.map +1 -1
- package/dist/modules/launcher/status_reporting.d.ts +6 -6
- package/dist/modules/launcher/status_reporting.d.ts.map +1 -1
- package/dist/modules/launcher/status_reporting.js +9 -6
- package/dist/modules/launcher/status_reporting.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/export.d.ts +1 -1
- package/dist/modules/order/export.js +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 +335 -43
- 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/logger.d.ts +23 -1
- package/dist/modules/order/logger.d.ts.map +1 -1
- package/dist/modules/order/logger.js +60 -17
- package/dist/modules/order/logger.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 +281 -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 +262 -167
- 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 +52 -4
- package/dist/modules/order/utils/system.d.ts.map +1 -1
- package/dist/modules/order/utils/system.js +109 -16
- package/dist/modules/order/utils/system.js.map +1 -1
- package/dist/modules/order/utils/validate.d.ts +6 -9
- package/dist/modules/order/utils/validate.d.ts.map +1 -1
- package/dist/modules/order/utils/validate.js +107 -85
- 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/modules/utils/chain_logs.d.ts +11 -0
- package/dist/modules/utils/chain_logs.d.ts.map +1 -0
- package/dist/modules/utils/chain_logs.js +23 -0
- package/dist/modules/utils/chain_logs.js.map +1 -0
- package/dist/modules/utils/sanitize_key.d.ts +7 -0
- package/dist/modules/utils/sanitize_key.d.ts.map +1 -1
- package/dist/modules/utils/sanitize_key.js +15 -0
- package/dist/modules/utils/sanitize_key.js.map +1 -1
- package/dist/pm2.d.ts +11 -1
- package/dist/pm2.d.ts.map +1 -1
- package/dist/pm2.js +66 -15
- package/dist/pm2.js.map +1 -1
- package/dist/scripts/analyze-credit.d.ts +4 -0
- package/dist/scripts/analyze-credit.d.ts.map +1 -0
- package/dist/scripts/analyze-credit.js +760 -0
- package/dist/scripts/analyze-credit.js.map +1 -0
- package/dist/scripts/analyze-orders.d.ts +1 -10
- package/dist/scripts/analyze-orders.d.ts.map +1 -1
- package/dist/scripts/analyze-orders.js +34 -36
- 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/dist/scripts/test-credit-renewal.d.ts.map +1 -1
- package/dist/scripts/test-credit-renewal.js +37 -5
- package/dist/scripts/test-credit-renewal.js.map +1 -1
- package/dist/scripts/tv.d.ts +11 -0
- package/dist/scripts/tv.d.ts.map +1 -0
- package/dist/scripts/tv.js +357 -0
- package/dist/scripts/tv.js.map +1 -0
- package/dist/scripts/update.js +77 -10
- package/dist/scripts/update.js.map +1 -1
- package/dist/unlock.d.ts.map +1 -1
- package/dist/unlock.js +20 -8
- package/dist/unlock.js.map +1 -1
- package/docs/DEXBOT_COMPARISON.md +11 -11
- package/docs/EVOLUTION.md +10 -8
- package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +1 -1
- package/docs/GRID_RECALCULATION.md +6 -0
- package/docs/MPA_CREDIT_USAGE.md +3 -1
- package/docs/README.md +2 -2
- package/docs/WORKFLOW.md +3 -1
- package/market_adapter/README.md +10 -0
- package/package.json +3 -1
- package/scripts/README.md +16 -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,161 @@ 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 CREATES
|
|
1449
|
+
* bypass per-action (see broadcast sites); rotations never bypass.
|
|
1450
|
+
* @param {number} price - Target order price
|
|
1451
|
+
* @param {number} size - Order size (unused, kept for compat)
|
|
1452
|
+
* @param {string} type - ORDER_TYPES.BUY/SELL
|
|
1453
|
+
* @param {number|null} lastPrice - Most recent fill price
|
|
1454
|
+
* @param {string|null} lastType - Most recent fill side (BUY/SELL)
|
|
1455
|
+
* @param {number|any} incrementPercent - Grid increment percent (e.g. 0.5). If assets object passed, falls back to default.
|
|
1456
|
+
* @returns {{blocked: boolean, pivot: number|null, halfInc: number, threshold: number|null}}
|
|
1457
|
+
*/
|
|
1458
|
+
function isLastFillGuardBlocked(price, _size, type, lastPrice, lastType, incrementPercent) {
|
|
1459
|
+
const numPrice = Number(price);
|
|
1460
|
+
if (!Number.isFinite(numPrice))
|
|
1461
|
+
return { blocked: false, pivot: null };
|
|
1462
|
+
if (lastPrice == null || !Number.isFinite(Number(lastPrice)) || lastType == null)
|
|
1463
|
+
return { blocked: false, pivot: null };
|
|
1464
|
+
const pivot = Number(lastPrice);
|
|
1465
|
+
// Resolve increment: fallback to default 0.5 (also covers legacy assets-object 6th arg)
|
|
1466
|
+
let inc = Number(incrementPercent);
|
|
1467
|
+
if (!Number.isFinite(inc) || inc <= 0) {
|
|
1468
|
+
inc = Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent ?? 0.5);
|
|
1469
|
+
}
|
|
1470
|
+
if (!Number.isFinite(inc) || inc <= 0)
|
|
1471
|
+
return { blocked: false, pivot: null };
|
|
1472
|
+
const halfInc = inc / 2;
|
|
1473
|
+
const halfPct = halfInc / 100;
|
|
1474
|
+
const buyThreshold = pivot * (1 - halfPct);
|
|
1475
|
+
const sellThreshold = pivot * (1 + halfPct);
|
|
1476
|
+
if (type === ORDER_TYPES.BUY && numPrice > buyThreshold)
|
|
1477
|
+
return { blocked: true, pivot, halfInc, threshold: buyThreshold };
|
|
1478
|
+
if (type === ORDER_TYPES.SELL && numPrice < sellThreshold)
|
|
1479
|
+
return { blocked: true, pivot, halfInc, threshold: sellThreshold };
|
|
1480
|
+
return { blocked: false, pivot: null, halfInc, threshold: null };
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Refresh the LAST-FILL guard pivot from fills that arrived while the current
|
|
1484
|
+
* batch was being planned or broadcast but have not been ingested yet.
|
|
1485
|
+
*
|
|
1486
|
+
* The pivot (_lastFilledPrice) is recorded per fill-processing batch, but a
|
|
1487
|
+
* multi-chunk COW broadcast spans tens of seconds — fills detected mid-cycle
|
|
1488
|
+
* sit in _incomingFillQueue until the cycle ends, so ops built for later
|
|
1489
|
+
* chunks would be checked against a stale pivot (production incident: a chunk
|
|
1490
|
+
* planned with a pre-crash pivot placed asks 0.6% below the true latest fill
|
|
1491
|
+
* that was already queued). Peek-only: never drains the queue, the owning
|
|
1492
|
+
* fill cycle still processes every entry. Best-effort: returns false when no
|
|
1493
|
+
* queued fill yields a usable side + price.
|
|
1494
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
1495
|
+
* @returns {boolean} True when the pivot was refreshed from queued fills
|
|
1496
|
+
*/
|
|
1497
|
+
function refreshLastFillPivotFromQueue(bot) {
|
|
1498
|
+
try {
|
|
1499
|
+
const queue = bot?._incomingFillQueue;
|
|
1500
|
+
if (!Array.isArray(queue) || queue.length === 0)
|
|
1501
|
+
return false;
|
|
1502
|
+
const mgr = bot?.manager;
|
|
1503
|
+
if (!mgr || !mgr.orders)
|
|
1504
|
+
return false;
|
|
1505
|
+
const assets = mgr.assets;
|
|
1506
|
+
const findSlotByOrderId = (orderId) => {
|
|
1507
|
+
if (!orderId)
|
|
1508
|
+
return null;
|
|
1509
|
+
try {
|
|
1510
|
+
for (const o of mgr.orders.values()) {
|
|
1511
|
+
if (o?.orderId === orderId)
|
|
1512
|
+
return o;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
catch { /* ignore iteration errors */ }
|
|
1516
|
+
return null;
|
|
1517
|
+
};
|
|
1518
|
+
let latest = null;
|
|
1519
|
+
for (const fill of queue) {
|
|
1520
|
+
const fillOp = fill?.op?.[1] || fill;
|
|
1521
|
+
const orderId = fillOp?.order_id || fill?.orderId;
|
|
1522
|
+
let type = null;
|
|
1523
|
+
let price = null;
|
|
1524
|
+
// Prefer the grid slot: a limit fill executes at (or better than)
|
|
1525
|
+
// its slot price, which is exactly the guard's price convention.
|
|
1526
|
+
const slot = findSlotByOrderId(orderId);
|
|
1527
|
+
if (slot && (slot.type === ORDER_TYPES.BUY || slot.type === ORDER_TYPES.SELL)) {
|
|
1528
|
+
type = slot.type;
|
|
1529
|
+
const slotPrice = Number(slot.price);
|
|
1530
|
+
if (Number.isFinite(slotPrice) && slotPrice > 0)
|
|
1531
|
+
price = slotPrice;
|
|
1532
|
+
}
|
|
1533
|
+
// Fall back to fill economics (B/A convention, mirroring
|
|
1534
|
+
// _computeFillContext's pays-asset side resolution, including
|
|
1535
|
+
// SPREAD slots carrying on-chain orders).
|
|
1536
|
+
if ((type == null || price == null) && fillOp?.pays && fillOp?.receives && assets?.assetA && assets?.assetB) {
|
|
1537
|
+
try {
|
|
1538
|
+
const paysId = fillOp.pays.asset_id;
|
|
1539
|
+
const paysAmt = Number(fillOp.pays.amount);
|
|
1540
|
+
const recvAmt = Number(fillOp.receives.amount);
|
|
1541
|
+
if (Number.isFinite(paysAmt) && paysAmt > 0 && Number.isFinite(recvAmt) && recvAmt > 0) {
|
|
1542
|
+
const paysFloat = (base, precision) => base / Math.pow(10, precision);
|
|
1543
|
+
if (paysId === assets.assetA.id) {
|
|
1544
|
+
type = ORDER_TYPES.SELL;
|
|
1545
|
+
price = paysFloat(recvAmt, assets.assetB.precision) / paysFloat(paysAmt, assets.assetA.precision);
|
|
1546
|
+
}
|
|
1547
|
+
else if (paysId === assets.assetB.id) {
|
|
1548
|
+
type = ORDER_TYPES.BUY;
|
|
1549
|
+
price = paysFloat(paysAmt, assets.assetB.precision) / paysFloat(recvAmt, assets.assetA.precision);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
catch { /* best-effort */ }
|
|
1554
|
+
}
|
|
1555
|
+
if ((type === ORDER_TYPES.BUY || type === ORDER_TYPES.SELL)
|
|
1556
|
+
&& Number.isFinite(price) && price > 0) {
|
|
1557
|
+
latest = { price: price, type };
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
if (!latest)
|
|
1561
|
+
return false;
|
|
1562
|
+
mgr._lastFilledPrice = latest.price;
|
|
1563
|
+
mgr._lastFilledType = latest.type;
|
|
1564
|
+
if (latest.type === ORDER_TYPES.BUY) {
|
|
1565
|
+
mgr._lastFilledBuyPrice = latest.price;
|
|
1566
|
+
}
|
|
1567
|
+
else {
|
|
1568
|
+
mgr._lastFilledSellPrice = latest.price;
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
mgr.logger?.log?.(`[LAST-FILL-GUARD] Pivot refreshed from ${queue.length} pending queued fill(s): ` +
|
|
1572
|
+
`${latest.type} @${Format.formatPrice6(latest.price)}`, 'debug');
|
|
1573
|
+
}
|
|
1574
|
+
catch { /* ignore logging errors */ }
|
|
1575
|
+
return true;
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Resolve the grid increment percent for the LAST-FILL guard in one place so
|
|
1583
|
+
* every check site and the batch summary use (and print) the same value.
|
|
1584
|
+
* Falls back to the default 0.5 when unset/invalid.
|
|
1585
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
1586
|
+
* @returns {number} Positive increment percent
|
|
1587
|
+
*/
|
|
1588
|
+
function resolveLastFillGuardIncrement(bot) {
|
|
1589
|
+
const raw = Number(bot?.manager?.config?.incrementPercent
|
|
1590
|
+
?? bot?.config?.incrementPercent
|
|
1591
|
+
?? constantsModule?.DEFAULT_CONFIG?.incrementPercent
|
|
1592
|
+
?? 0.5);
|
|
1593
|
+
if (Number.isFinite(raw) && raw > 0)
|
|
1594
|
+
return raw;
|
|
1595
|
+
return Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent) > 0
|
|
1596
|
+
? Number(constantsModule?.DEFAULT_CONFIG?.incrementPercent)
|
|
1597
|
+
: 0.5;
|
|
1598
|
+
}
|
|
1443
1599
|
/**
|
|
1444
1600
|
* Build COW actions array from a simple plan object.
|
|
1445
1601
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
@@ -1451,6 +1607,12 @@ function buildActionsFromPlan(_bot, plan) {
|
|
|
1451
1607
|
? { ordersToPlace: plan }
|
|
1452
1608
|
: (plan || {});
|
|
1453
1609
|
const { ordersToPlace = [], ordersToRotate = [], ordersToUpdate = [], ordersToCancel = [] } = normalizedPlan;
|
|
1610
|
+
// Per-action origin: guard bypasses are scoped per action (not per batch),
|
|
1611
|
+
// so a future rotation entry merged into a correction plan cannot silently
|
|
1612
|
+
// inherit the spread-correction bypass. Actions built outside this helper
|
|
1613
|
+
// carry no origin and default to guarded (safe default).
|
|
1614
|
+
const planOrigin = normalizedPlan?.origin;
|
|
1615
|
+
const withOrigin = (action) => (planOrigin ? { ...action, origin: planOrigin } : action);
|
|
1454
1616
|
const actions = [];
|
|
1455
1617
|
for (const o of ordersToCancel) {
|
|
1456
1618
|
if (o?.orderId) {
|
|
@@ -1471,7 +1633,7 @@ function buildActionsFromPlan(_bot, plan) {
|
|
|
1471
1633
|
const orderType = r?.type || oldOrder?.type;
|
|
1472
1634
|
if (!id || !orderId || !newGridId || !orderType || !Number.isFinite(newPrice) || !(newSize > 0))
|
|
1473
1635
|
continue;
|
|
1474
|
-
actions.push({
|
|
1636
|
+
actions.push(withOrigin({
|
|
1475
1637
|
type: COW_ACTIONS.UPDATE,
|
|
1476
1638
|
id,
|
|
1477
1639
|
orderId,
|
|
@@ -1484,7 +1646,7 @@ function buildActionsFromPlan(_bot, plan) {
|
|
|
1484
1646
|
price: newPrice,
|
|
1485
1647
|
size: newSize
|
|
1486
1648
|
}
|
|
1487
|
-
});
|
|
1649
|
+
}));
|
|
1488
1650
|
}
|
|
1489
1651
|
for (const o of ordersToUpdate) {
|
|
1490
1652
|
const partialOrder = o?.partialOrder || o;
|
|
@@ -1496,7 +1658,7 @@ function buildActionsFromPlan(_bot, plan) {
|
|
|
1496
1658
|
: Number(partialOrder?.size || 0);
|
|
1497
1659
|
if (!id || !orderId)
|
|
1498
1660
|
continue;
|
|
1499
|
-
actions.push({
|
|
1661
|
+
actions.push(withOrigin({
|
|
1500
1662
|
type: COW_ACTIONS.UPDATE,
|
|
1501
1663
|
id,
|
|
1502
1664
|
orderId,
|
|
@@ -1508,12 +1670,12 @@ function buildActionsFromPlan(_bot, plan) {
|
|
|
1508
1670
|
type: orderType,
|
|
1509
1671
|
size: newSize
|
|
1510
1672
|
}
|
|
1511
|
-
});
|
|
1673
|
+
}));
|
|
1512
1674
|
}
|
|
1513
1675
|
for (const o of ordersToPlace) {
|
|
1514
1676
|
if (!o?.id)
|
|
1515
1677
|
continue;
|
|
1516
|
-
actions.push({ type: COW_ACTIONS.CREATE, id: o.id, order: o });
|
|
1678
|
+
actions.push(withOrigin({ type: COW_ACTIONS.CREATE, id: o.id, order: o }));
|
|
1517
1679
|
}
|
|
1518
1680
|
return actions;
|
|
1519
1681
|
}
|
|
@@ -1597,7 +1759,8 @@ function buildCowResultFromPlan(bot, plan) {
|
|
|
1597
1759
|
workingGrid,
|
|
1598
1760
|
workingIndexes: workingGrid.getIndexes(),
|
|
1599
1761
|
workingBoundary,
|
|
1600
|
-
actions
|
|
1762
|
+
actions,
|
|
1763
|
+
origin: plan?.origin
|
|
1601
1764
|
};
|
|
1602
1765
|
}
|
|
1603
1766
|
/**
|
|
@@ -1821,7 +1984,10 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
|
|
|
1821
1984
|
bot.manager._boundaryShiftBudget = bot.manager._boundaryShiftBudgetBase;
|
|
1822
1985
|
}
|
|
1823
1986
|
if (typeof bot.manager.performSafeRebalance === 'function') {
|
|
1824
|
-
|
|
1987
|
+
// skipBroadcastWait: this frame is itself inside the executor's
|
|
1988
|
+
// startBroadcasting() region — waiting on the flag we hold would
|
|
1989
|
+
// stall the re-plan for the full _awaitBroadcastIdle timeout.
|
|
1990
|
+
replanned = await bot.manager.performSafeRebalance(cowResult.fills, cowResult.excludeIds || new Set(), { skipBroadcastWait: true });
|
|
1825
1991
|
}
|
|
1826
1992
|
}
|
|
1827
1993
|
catch (replanErr) {
|
|
@@ -1883,12 +2049,19 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
|
|
|
1883
2049
|
* synchronously (before the next await) once this resolves so the
|
|
1884
2050
|
* check-and-set stays atomic and two planning batches cannot both win the
|
|
1885
2051
|
* broadcast slot.
|
|
2052
|
+
*
|
|
2053
|
+
* Returns true when the caller must abort the batch (shutdown began before or
|
|
2054
|
+
* during the wait — proceeding would broadcast with post-shutdown state),
|
|
2055
|
+
* false when the caller may proceed to claim the broadcast slot.
|
|
1886
2056
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
1887
2057
|
* @param {string} label - Stage label for the deferral log (e.g. 'entry', 'pre-broadcast')
|
|
2058
|
+
* @returns {Promise<boolean>}
|
|
1888
2059
|
*/
|
|
1889
2060
|
async function waitForCowBroadcastSingleFlight(bot, label) {
|
|
2061
|
+
if (bot._shuttingDown)
|
|
2062
|
+
return true;
|
|
1890
2063
|
if (!bot._cowBroadcastInFlight)
|
|
1891
|
-
return;
|
|
2064
|
+
return false;
|
|
1892
2065
|
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
2066
|
const waitDeadline = Date.now() + SINGLE_FLIGHT_MAX_WAIT_MS;
|
|
1894
2067
|
while (bot._cowBroadcastInFlight) {
|
|
@@ -1896,9 +2069,14 @@ async function waitForCowBroadcastSingleFlight(bot, label) {
|
|
|
1896
2069
|
break;
|
|
1897
2070
|
await sleep(250);
|
|
1898
2071
|
}
|
|
2072
|
+
if (bot._shuttingDown) {
|
|
2073
|
+
bot.manager.logger.log(`[COW] ${label}: shutdown began while waiting for the in-flight broadcast; aborting this batch.`, 'warn');
|
|
2074
|
+
return true;
|
|
2075
|
+
}
|
|
1899
2076
|
if (bot._cowBroadcastInFlight) {
|
|
1900
2077
|
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
2078
|
}
|
|
2079
|
+
return false;
|
|
1902
2080
|
}
|
|
1903
2081
|
/**
|
|
1904
2082
|
* COW broadcast: Execute blockchain operations and commit working grid on success.
|
|
@@ -1988,6 +2166,48 @@ function detectCrossedBookPlan(manager, actions) {
|
|
|
1988
2166
|
return null;
|
|
1989
2167
|
}
|
|
1990
2168
|
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Derive an update action's planned target size (shared by the rotation and
|
|
2171
|
+
* plain size-update op builders).
|
|
2172
|
+
* @param {Object} action - COW action
|
|
2173
|
+
* @returns {number}
|
|
2174
|
+
*/
|
|
2175
|
+
function plannedUpdateSize(action) {
|
|
2176
|
+
return Number.isFinite(Number(action.newSize))
|
|
2177
|
+
? Number(action.newSize)
|
|
2178
|
+
: Number(action.order?.size || 0);
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Post-fill size invariant for COW UPDATE ops: a partially-filled order
|
|
2182
|
+
* (slot state PARTIAL — the fill is already booked into slot.size) must
|
|
2183
|
+
* never be GROWN in place by a plan update. Growing it would restore the
|
|
2184
|
+
* pre-fill size on chain while the fill accounting stays on the booked
|
|
2185
|
+
* remaining size — chain and books diverge and the fill effectively
|
|
2186
|
+
* vanishes from the bot's ledger. Clamp the target to the booked remaining
|
|
2187
|
+
* size and keep the plan's price intent; a deliberate full-size top-up must
|
|
2188
|
+
* go through a cancel+create cycle, not a silent in-place grow.
|
|
2189
|
+
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
2190
|
+
* @param {Object} masterOrder - Live master-grid slot for the action
|
|
2191
|
+
* @param {number} newSize - Plan's target size for the update op
|
|
2192
|
+
* @param {Object} action - The COW action being built
|
|
2193
|
+
* @returns {number} The (possibly clamped) target size
|
|
2194
|
+
*/
|
|
2195
|
+
function clampPostFillUpdateSize(bot, masterOrder, newSize, action) {
|
|
2196
|
+
const target = Number(newSize);
|
|
2197
|
+
if (!masterOrder || masterOrder.state !== ORDER_STATES.PARTIAL)
|
|
2198
|
+
return target;
|
|
2199
|
+
const booked = Number(masterOrder.size);
|
|
2200
|
+
if (!Number.isFinite(target) || !Number.isFinite(booked) || booked <= 0)
|
|
2201
|
+
return target;
|
|
2202
|
+
if (target > booked) {
|
|
2203
|
+
bot.manager.logger.log(`[COW] Post-fill size clamp for ${action?.id || masterOrder?.id}: slot is PARTIAL with ` +
|
|
2204
|
+
`booked remaining ${Format.formatAmount(booked)} but the plan targets ` +
|
|
2205
|
+
`${Format.formatAmount(target)} — clamping to booked remaining. ` +
|
|
2206
|
+
`A partially-filled order must not be grown in place by a COW update (fill accounting divergence).`, 'warn');
|
|
2207
|
+
return booked;
|
|
2208
|
+
}
|
|
2209
|
+
return target;
|
|
2210
|
+
}
|
|
1991
2211
|
async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
1992
2212
|
const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
|
|
1993
2213
|
bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
|
|
@@ -2012,7 +2232,34 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2012
2232
|
// -> snapshot reload that can drop the adopted order and produce an
|
|
2013
2233
|
// orphan fill. This entry wait is an optimization; the authoritative
|
|
2014
2234
|
// atomic check-and-set happens right before the broadcast below.
|
|
2015
|
-
await waitForCowBroadcastSingleFlight(bot, 'entry')
|
|
2235
|
+
if (await waitForCowBroadcastSingleFlight(bot, 'entry')) {
|
|
2236
|
+
popPushedWorkingGrid(bot, cowResult);
|
|
2237
|
+
return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
|
|
2238
|
+
}
|
|
2239
|
+
// DRAIN PENDING CORRECTIONS before the batch is planned/broadcast.
|
|
2240
|
+
// Cancel-only corrections (duplicate-price orphans) queued by an earlier
|
|
2241
|
+
// sync must not sit while batches run back-to-back (startup create
|
|
2242
|
+
// groups, fill bursts) — with the open-orders sync loop disabled they
|
|
2243
|
+
// would otherwise linger indefinitely, keep blocking same-level CREATEs,
|
|
2244
|
+
// and risk cancelling the wrong side of a duplicate later. Draining here
|
|
2245
|
+
// also keeps this batch's collision checks (chain_orphan_collision)
|
|
2246
|
+
// honest: orphaned chain orders already queued for cancellation are
|
|
2247
|
+
// resolved before the plan validates its CREATE targets against them.
|
|
2248
|
+
const pendingCorrectionCount = Array.isArray(bot.manager?.ordersNeedingPriceCorrection)
|
|
2249
|
+
? bot.manager.ordersNeedingPriceCorrection.length
|
|
2250
|
+
: 0;
|
|
2251
|
+
if (pendingCorrectionCount > 0 && !bot._shuttingDown) {
|
|
2252
|
+
try {
|
|
2253
|
+
bot.manager.logger.log(`[COW] Draining ${pendingCorrectionCount} pending correction(s) before batch`, 'info');
|
|
2254
|
+
const drainResult = await orderUtils.correctAllPriceMismatches(bot.manager, bot.account, bot.privateKey, chainOrders);
|
|
2255
|
+
if (drainResult?.failed > 0) {
|
|
2256
|
+
bot.manager.logger.log(`[COW] ${drainResult.failed} correction(s) failed pre-batch; remaining entries retry on next sync/maintenance tick`, 'warn');
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
catch (drainErr) {
|
|
2260
|
+
bot.manager.logger.log(`[COW] Pre-batch correction drain failed: ${getErrorMessage(drainErr)}`, 'warn');
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2016
2263
|
const chainOrderCandidates = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
|
|
2017
2264
|
? bot.manager._lastUnmatchedChainOrders
|
|
2018
2265
|
: [];
|
|
@@ -2196,6 +2443,11 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2196
2443
|
hadRotation: false
|
|
2197
2444
|
};
|
|
2198
2445
|
}
|
|
2446
|
+
// Crossing-check candidate set (master + pending-broadcast + unmatched
|
|
2447
|
+
// chain orders). Built after the batch-level pending/unmatched guards so
|
|
2448
|
+
// it reflects any sync they triggered.
|
|
2449
|
+
const crossingCandidates = buildCrossingCandidates(bot);
|
|
2450
|
+
const intraBatchCandidates = [];
|
|
2199
2451
|
const { assetA, assetB } = bot.manager.assets;
|
|
2200
2452
|
// CROSSED-BOOK GATE: refuse to broadcast any batch whose simulated result
|
|
2201
2453
|
// prices a BUY at-or-above a SELL (see detectCrossedBookPlan).
|
|
@@ -2215,6 +2467,25 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2215
2467
|
const opContexts = [];
|
|
2216
2468
|
const skippedUpdateSlotIds = new Set();
|
|
2217
2469
|
let skippedUpdateCount = 0;
|
|
2470
|
+
// Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
|
|
2471
|
+
// would spam big batches, so the guard emits one batch summary instead
|
|
2472
|
+
// (see the summary after the action loop below).
|
|
2473
|
+
const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0 };
|
|
2474
|
+
// Whether any guard check in this batch refreshed the pivot from
|
|
2475
|
+
// still-queued fills — reported in the batch summary so a pivot change
|
|
2476
|
+
// that altered a guard decision is visible at info, not just debug.
|
|
2477
|
+
let lastFillGuardPivotRefreshed = false;
|
|
2478
|
+
// Slots whose size-update op was broadcast with a post-fill-clamped
|
|
2479
|
+
// target: the working grid still holds the planned (larger) size, so the
|
|
2480
|
+
// slots are re-synced from master before commit to keep the committed
|
|
2481
|
+
// books equal to the broadcast chain amounts.
|
|
2482
|
+
const clampedUpdateSlotIds = new Set();
|
|
2483
|
+
// orderId -> operations index of its cancel op. A crossing re-pricing
|
|
2484
|
+
// update is only safe when the crossed order's cancel was already queued
|
|
2485
|
+
// at an earlier position: ops broadcast in MAX_OPS_PER_BROADCAST chunks,
|
|
2486
|
+
// so an earlier index means the cancel confirms on chain (same or earlier
|
|
2487
|
+
// chunk, applied sequentially) before the crossing order lands.
|
|
2488
|
+
const cancelOpIndexByOrderId = new Map();
|
|
2218
2489
|
const idsToLock = new Set();
|
|
2219
2490
|
for (const action of actions) {
|
|
2220
2491
|
if (action.type === COW_ACTIONS.CANCEL && action.orderId) {
|
|
@@ -2250,6 +2521,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2250
2521
|
try {
|
|
2251
2522
|
const op = await chainOrders.buildCancelOrderOp(bot.account, action.orderId);
|
|
2252
2523
|
operations.push(op);
|
|
2524
|
+
if (action.orderId)
|
|
2525
|
+
cancelOpIndexByOrderId.set(action.orderId, operations.length - 1);
|
|
2253
2526
|
const order = bot.manager.orders.get(action.id) || { id: action.id, orderId: action.orderId };
|
|
2254
2527
|
opContexts.push({ kind: 'cancel', order });
|
|
2255
2528
|
}
|
|
@@ -2286,8 +2559,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2286
2559
|
`(diff=${priceDrift}); rebuilding CREATE op with live price.`, 'debug');
|
|
2287
2560
|
}
|
|
2288
2561
|
const createPrice = effectiveOrder.price;
|
|
2289
|
-
const
|
|
2290
|
-
const batchCollision =
|
|
2562
|
+
const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
|
|
2563
|
+
const batchCollision = hasSlotPriceCollision(opContexts, createPrice, precision, order.id, (ctx) => ctx.kind === 'create' && ctx.order?.price != null);
|
|
2291
2564
|
if (batchCollision) {
|
|
2292
2565
|
bot.manager.logger.log(`[COW] Skipping CREATE for ${order.id} at ${Format.formatPrice6(createPrice)}: ` +
|
|
2293
2566
|
`same-batch CREATE ${batchCollision.id} already at ` +
|
|
@@ -2295,6 +2568,60 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2295
2568
|
`The next reconcile cycle will resolve the mismatch.`, 'warn');
|
|
2296
2569
|
continue;
|
|
2297
2570
|
}
|
|
2571
|
+
// CROSSING-PLACEMENT GUARD (create variant): the batch-level
|
|
2572
|
+
// validators (validateCreateTargetSlots, detectCrossedBookPlan)
|
|
2573
|
+
// simulated the PLANNED price, but the pre-broadcast price
|
|
2574
|
+
// freshness rebuild above can move the op's price. Re-check
|
|
2575
|
+
// crossing on the FINAL price against live and chain-side
|
|
2576
|
+
// orders not already cancelled at an earlier op position —
|
|
2577
|
+
// an opposite-side order cancelled in a later chunk would
|
|
2578
|
+
// otherwise coexist with this create mid-broadcast and
|
|
2579
|
+
// self-trade (production incident class).
|
|
2580
|
+
const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => {
|
|
2581
|
+
const oid = o?.orderId || o?.chainOrderId;
|
|
2582
|
+
return o && oid && !cancelOpIndexByOrderId.has(oid);
|
|
2583
|
+
});
|
|
2584
|
+
const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
|
|
2585
|
+
const effectiveCrossed = createCrossed || intraBatchCrossed;
|
|
2586
|
+
if (effectiveCrossed) {
|
|
2587
|
+
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE for ${order.id} at ` +
|
|
2588
|
+
`${Format.formatPrice6(createPrice)}: crosses live ` +
|
|
2589
|
+
`${crossedOrderLabel(effectiveCrossed)}; re-planned after its cancel confirms.`, 'warn');
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
// LAST-FILL PRICE GUARD: pivot ± halfIncrement (BUY < pivot*(1-half), SELL > pivot*(1+half)).
|
|
2593
|
+
// Bypass is per-action: only spread-correction CREATES skip so gap repair can close.
|
|
2594
|
+
// The batch-level origin is honored only for actions without their own origin stamp
|
|
2595
|
+
// (back-compat for plans that bypass buildActionsFromPlan). Cold (null) => disabled.
|
|
2596
|
+
try {
|
|
2597
|
+
const actionOrigin = action?.origin;
|
|
2598
|
+
const batchOrigin = cowResult?.origin;
|
|
2599
|
+
const isCorrectionCreate = actionOrigin === 'spread-correction'
|
|
2600
|
+
|| (actionOrigin == null && batchOrigin === 'spread-correction');
|
|
2601
|
+
if (!isCorrectionCreate) {
|
|
2602
|
+
try {
|
|
2603
|
+
if (refreshLastFillPivotFromQueue(bot))
|
|
2604
|
+
lastFillGuardPivotRefreshed = true;
|
|
2605
|
+
}
|
|
2606
|
+
catch { /* best-effort */ }
|
|
2607
|
+
const lastPrice = bot.manager?._lastFilledPrice;
|
|
2608
|
+
const lastType = bot.manager?._lastFilledType;
|
|
2609
|
+
const inc = resolveLastFillGuardIncrement(bot);
|
|
2610
|
+
const check = isLastFillGuardBlocked(createPrice, order.size, order.type, lastPrice, lastType, inc);
|
|
2611
|
+
lastFillGuardStats.checked++;
|
|
2612
|
+
if (check.blocked) {
|
|
2613
|
+
lastFillGuardStats.skipped++;
|
|
2614
|
+
const dir = order.type === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
2615
|
+
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');
|
|
2616
|
+
continue;
|
|
2617
|
+
}
|
|
2618
|
+
lastFillGuardStats.passed++;
|
|
2619
|
+
}
|
|
2620
|
+
else {
|
|
2621
|
+
lastFillGuardStats.bypassed++;
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
catch (_e) { /* guard is best-effort */ }
|
|
2298
2625
|
const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
|
|
2299
2626
|
const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
|
|
2300
2627
|
if (!buildResult) {
|
|
@@ -2303,6 +2630,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2303
2630
|
}
|
|
2304
2631
|
operations.push(buildResult.op);
|
|
2305
2632
|
opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
|
|
2633
|
+
intraBatchCandidates.push(effectiveOrder);
|
|
2306
2634
|
recordPendingBroadcast(bot, {
|
|
2307
2635
|
opIndex: operations.length - 1,
|
|
2308
2636
|
ctxIndex: opContexts.length - 1,
|
|
@@ -2322,17 +2650,99 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2322
2650
|
const newPrice = Number.isFinite(Number(action.newPrice))
|
|
2323
2651
|
? Number(action.newPrice)
|
|
2324
2652
|
: Number(action.order?.price);
|
|
2325
|
-
const newSize =
|
|
2326
|
-
? Number(action.newSize)
|
|
2327
|
-
: Number(action.order?.size || 0);
|
|
2653
|
+
const newSize = plannedUpdateSize(action);
|
|
2328
2654
|
if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
|
|
2329
2655
|
continue;
|
|
2330
2656
|
}
|
|
2657
|
+
// POST-FILL GROWTH GUARD (rotation): a rotation must
|
|
2658
|
+
// not GROW a partially-filled order back above its
|
|
2659
|
+
// booked remaining size — the fill is already booked
|
|
2660
|
+
// into slot.size, and growing in place diverges the
|
|
2661
|
+
// fill accounting (chain restores the pre-fill size
|
|
2662
|
+
// while the books keep the post-fill remainder). Skip
|
|
2663
|
+
// like the guards above: the working grid restores the
|
|
2664
|
+
// slot from master and the next plan re-evaluates.
|
|
2665
|
+
if (masterOrder.state === ORDER_STATES.PARTIAL
|
|
2666
|
+
&& Number.isFinite(Number(masterOrder.size))
|
|
2667
|
+
&& Number(masterOrder.size) > 0
|
|
2668
|
+
&& newSize > Number(masterOrder.size)) {
|
|
2669
|
+
skippedUpdateCount++;
|
|
2670
|
+
if (action.id)
|
|
2671
|
+
skippedUpdateSlotIds.add(action.id);
|
|
2672
|
+
if (action.newGridId)
|
|
2673
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
2674
|
+
bot.manager.logger.log(`[COW] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
|
|
2675
|
+
`slot is PARTIAL with booked remaining ${Format.formatAmount(Number(masterOrder.size))} ` +
|
|
2676
|
+
`but the plan targets ${Format.formatAmount(newSize)} — a partially-filled order ` +
|
|
2677
|
+
`must not be grown in place (fill accounting divergence).`, 'warn');
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2331
2680
|
const rotationSizeValidation = validateOrderSizeForExecution(bot, newSize, orderType, action.order, newSize);
|
|
2332
2681
|
if (!rotationSizeValidation.isValid) {
|
|
2333
2682
|
bot.manager.logger.log(`Skipping rotation update ${action.id} -> ${action.newGridId}: ${rotationSizeValidation.reason}`, 'warn');
|
|
2334
2683
|
continue;
|
|
2335
2684
|
}
|
|
2685
|
+
// CROSSING-PLACEMENT GUARD: re-pricing an order must
|
|
2686
|
+
// never cross an opposite-side live order whose cancel
|
|
2687
|
+
// is not already queued at an earlier op position.
|
|
2688
|
+
// Re-pricing a buy upward across our own live sell
|
|
2689
|
+
// ladder self-trades during the chunked broadcast
|
|
2690
|
+
// window (production incident: a startup buy was
|
|
2691
|
+
// re-priced upward and filled against our own live
|
|
2692
|
+
// opposite-side sells that this same plan was still
|
|
2693
|
+
// cancelling in later chunks — dozens of self-fills,
|
|
2694
|
+
// fatal fund assert). Skipping is safe: the slot keeps its
|
|
2695
|
+
// old commitment and the next plan re-evaluates once
|
|
2696
|
+
// the crossed order's cancel confirms.
|
|
2697
|
+
const crossedOrder = findCrossedOrder([...crossingCandidates, ...intraBatchCandidates], newPrice, orderType, bot.manager.assets, (o) => {
|
|
2698
|
+
const oid = o?.orderId || o?.chainOrderId;
|
|
2699
|
+
return o
|
|
2700
|
+
&& oid
|
|
2701
|
+
&& oid !== action.orderId
|
|
2702
|
+
&& !cancelOpIndexByOrderId.has(oid);
|
|
2703
|
+
});
|
|
2704
|
+
if (crossedOrder) {
|
|
2705
|
+
skippedUpdateCount++;
|
|
2706
|
+
if (action.id)
|
|
2707
|
+
skippedUpdateSlotIds.add(action.id);
|
|
2708
|
+
if (action.newGridId)
|
|
2709
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
2710
|
+
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping rotation update ${action.id} -> ${action.newGridId}: ` +
|
|
2711
|
+
`new ${orderType} @${Format.formatPrice6(newPrice)} crosses live ` +
|
|
2712
|
+
`${crossedOrderLabel(crossedOrder)}; re-planned after its cancel confirms.`, 'warn');
|
|
2713
|
+
continue;
|
|
2714
|
+
}
|
|
2715
|
+
// LAST-FILL PRICE GUARD (UPDATE rotation): pivot ± halfIncrement — same as CREATE.
|
|
2716
|
+
// No origin bypass here, ever: a rotation reprices, so it always obeys the guard —
|
|
2717
|
+
// even if a future correction plan carries rotation entries (per-action scoping).
|
|
2718
|
+
// The pivot is refreshed from still-queued fills first: fills detected mid-broadcast
|
|
2719
|
+
// sit in _incomingFillQueue until the fill cycle ends, and without this the later
|
|
2720
|
+
// chunks of a long broadcast would be checked against a stale pivot.
|
|
2721
|
+
try {
|
|
2722
|
+
try {
|
|
2723
|
+
if (refreshLastFillPivotFromQueue(bot))
|
|
2724
|
+
lastFillGuardPivotRefreshed = true;
|
|
2725
|
+
}
|
|
2726
|
+
catch { /* best-effort */ }
|
|
2727
|
+
const lastPrice = bot.manager?._lastFilledPrice;
|
|
2728
|
+
const lastType = bot.manager?._lastFilledType;
|
|
2729
|
+
const inc = resolveLastFillGuardIncrement(bot);
|
|
2730
|
+
const check = isLastFillGuardBlocked(newPrice, newSize, orderType, lastPrice, lastType, inc);
|
|
2731
|
+
lastFillGuardStats.checked++;
|
|
2732
|
+
if (check.blocked) {
|
|
2733
|
+
lastFillGuardStats.skipped++;
|
|
2734
|
+
skippedUpdateCount++;
|
|
2735
|
+
if (action.id)
|
|
2736
|
+
skippedUpdateSlotIds.add(action.id);
|
|
2737
|
+
if (action.newGridId)
|
|
2738
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
2739
|
+
const dir = orderType === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
2740
|
+
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');
|
|
2741
|
+
continue;
|
|
2742
|
+
}
|
|
2743
|
+
lastFillGuardStats.passed++;
|
|
2744
|
+
}
|
|
2745
|
+
catch (_e) { /* best-effort */ }
|
|
2336
2746
|
const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
|
|
2337
2747
|
const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
|
|
2338
2748
|
if (!buildResult) {
|
|
@@ -2356,12 +2766,15 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2356
2766
|
},
|
|
2357
2767
|
finalInts: buildResult.finalInts
|
|
2358
2768
|
});
|
|
2769
|
+
intraBatchCandidates.push({ type: orderType, price: newPrice, orderId: action.orderId, id: action.newGridId || action.id });
|
|
2359
2770
|
continue;
|
|
2360
2771
|
}
|
|
2361
|
-
const newSize = Number.isFinite(Number(action.newSize))
|
|
2362
|
-
? Number(action.newSize)
|
|
2363
|
-
: Number(action.order?.size || 0);
|
|
2364
2772
|
const masterOrder = bot.manager.orders.get(action.id);
|
|
2773
|
+
const plannedNewSize = plannedUpdateSize(action);
|
|
2774
|
+
const newSize = clampPostFillUpdateSize(bot, masterOrder, plannedNewSize, action);
|
|
2775
|
+
if (newSize !== plannedNewSize) {
|
|
2776
|
+
clampedUpdateSlotIds.add(action.id);
|
|
2777
|
+
}
|
|
2365
2778
|
const orderType = action.order?.type || masterOrder?.type;
|
|
2366
2779
|
const cachedRawOnChain = masterOrder?.rawOnChain || action.order?.rawOnChain || null;
|
|
2367
2780
|
const op = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell: newSize, orderType }, cachedRawOnChain);
|
|
@@ -2375,6 +2788,8 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2375
2788
|
continue;
|
|
2376
2789
|
}
|
|
2377
2790
|
operations.push(op.op);
|
|
2791
|
+
if (masterOrder?.price != null)
|
|
2792
|
+
intraBatchCandidates.push({ type: orderType, price: masterOrder.price, orderId: action.orderId, id: action.id });
|
|
2378
2793
|
const partialOrder = masterOrder || {
|
|
2379
2794
|
id: action.id,
|
|
2380
2795
|
orderId: action.orderId,
|
|
@@ -2406,20 +2821,63 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2406
2821
|
bot.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
|
|
2407
2822
|
}
|
|
2408
2823
|
else if (fbType && fbSize > 0 && fbPrice > 0) {
|
|
2409
|
-
const
|
|
2824
|
+
const fbPrecision = fbType === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
|
|
2825
|
+
const fbCollision = hasSlotPriceCollision([...bot.manager.orders.values()], fbPrice, fbPrecision, targetSlotId, isOrderPlaced);
|
|
2410
2826
|
if (fbCollision) {
|
|
2411
2827
|
bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
|
|
2412
2828
|
`existing placed order ${fbCollision.id} (${fbCollision.orderId}) ` +
|
|
2413
2829
|
`already at price ${Format.formatPrice6(fbCollision.price)}.`, 'warn');
|
|
2414
2830
|
continue;
|
|
2415
2831
|
}
|
|
2416
|
-
const fbBatchCollision =
|
|
2832
|
+
const fbBatchCollision = hasSlotPriceCollision(opContexts, fbPrice, fbPrecision, targetSlotId, (ctx) => ctx.kind === 'create');
|
|
2417
2833
|
if (fbBatchCollision) {
|
|
2418
2834
|
bot.manager.logger.log(`[COW] Skipping CREATE fallback for ${targetSlotId} at ${Format.formatPrice6(fbPrice)}: ` +
|
|
2419
2835
|
`same-batch CREATE ${fbBatchCollision.id} already at ` +
|
|
2420
2836
|
`price ${Format.formatPrice6(fbBatchCollision.order.price)}.`, 'warn');
|
|
2421
2837
|
continue;
|
|
2422
2838
|
}
|
|
2839
|
+
// CROSSING-PLACEMENT GUARD (fallback variant):
|
|
2840
|
+
// the not-found conversion re-prices to the
|
|
2841
|
+
// rotation's target price, so it must obey the
|
|
2842
|
+
// same crossing rule as the rotation UPDATE it
|
|
2843
|
+
// replaces — no crossing of an opposite-side
|
|
2844
|
+
// live order whose cancel is not already
|
|
2845
|
+
// queued at an earlier op position.
|
|
2846
|
+
const fbCrossed = findCrossedOrder(crossingCandidates, fbPrice, fbType, bot.manager.assets, (o) => {
|
|
2847
|
+
const oid = o?.orderId || o?.chainOrderId;
|
|
2848
|
+
return o && oid && !cancelOpIndexByOrderId.has(oid);
|
|
2849
|
+
});
|
|
2850
|
+
if (fbCrossed) {
|
|
2851
|
+
bot.manager.logger.log(`[COW-CROSS-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
|
|
2852
|
+
`${Format.formatPrice6(fbPrice)}: new ${fbType} crosses live ` +
|
|
2853
|
+
`${crossedOrderLabel(fbCrossed)}; re-planned after its cancel confirms.`, 'warn');
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
// LAST-FILL GUARD (fallback variant): this CREATE
|
|
2857
|
+
// replaces a rotation UPDATE at a repriced level,
|
|
2858
|
+
// so it obeys the same guard with a refreshed
|
|
2859
|
+
// pivot — no origin bypass, same as rotations.
|
|
2860
|
+
try {
|
|
2861
|
+
if (refreshLastFillPivotFromQueue(bot))
|
|
2862
|
+
lastFillGuardPivotRefreshed = true;
|
|
2863
|
+
}
|
|
2864
|
+
catch { /* best-effort */ }
|
|
2865
|
+
try {
|
|
2866
|
+
const fbInc = resolveLastFillGuardIncrement(bot);
|
|
2867
|
+
const fbCheck = isLastFillGuardBlocked(fbPrice, fbSize, fbType, bot.manager?._lastFilledPrice, bot.manager?._lastFilledType, fbInc);
|
|
2868
|
+
lastFillGuardStats.checked++;
|
|
2869
|
+
if (fbCheck.blocked) {
|
|
2870
|
+
lastFillGuardStats.skipped++;
|
|
2871
|
+
const fbDir = fbType === ORDER_TYPES.BUY ? 'above' : 'below';
|
|
2872
|
+
bot.manager.logger.log(`[LAST-FILL-GUARD] Skipping CREATE fallback for ${targetSlotId} at ` +
|
|
2873
|
+
`${Format.formatPrice6(fbPrice)}: ${fbDir} last filled ` +
|
|
2874
|
+
`${Format.formatPrice6(fbCheck.pivot)} (halfInc ${fbCheck.halfInc}% thr ` +
|
|
2875
|
+
`${Format.formatPrice6(fbCheck.threshold)}); re-planned after market moves`, 'warn');
|
|
2876
|
+
continue;
|
|
2877
|
+
}
|
|
2878
|
+
lastFillGuardStats.passed++;
|
|
2879
|
+
}
|
|
2880
|
+
catch (_fbGuardErr) { /* guard is best-effort */ }
|
|
2423
2881
|
const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
|
|
2424
2882
|
const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
|
|
2425
2883
|
if (fbResult) {
|
|
@@ -2450,9 +2908,51 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2450
2908
|
}
|
|
2451
2909
|
}
|
|
2452
2910
|
}
|
|
2911
|
+
// Batch-level LAST-FILL-GUARD summary: per-action pass lines would spam
|
|
2912
|
+
// big batches, so one line per batch records the mode, pivot, resolved
|
|
2913
|
+
// increment, and pass/skip/bypass counts — the guard's pass decisions
|
|
2914
|
+
// are what incident reconstruction needs. Origin folds in here as
|
|
2915
|
+
// mode=bypassed(<origin>); there is no second source of truth.
|
|
2916
|
+
// Cold (guard off) is warn, not info — a disabled guard must say so.
|
|
2917
|
+
// pivotRefreshed surfaces whether a mid-broadcast queued fill moved the
|
|
2918
|
+
// pivot under this batch's checks (the refresh itself stays debug).
|
|
2919
|
+
// Note the printed pivot is end-of-batch state: when pivotRefreshed is
|
|
2920
|
+
// true, early actions were checked against the older pivot.
|
|
2921
|
+
try {
|
|
2922
|
+
const totalGuarded = lastFillGuardStats.checked + lastFillGuardStats.bypassed;
|
|
2923
|
+
if (totalGuarded > 0) {
|
|
2924
|
+
const sumPivotRaw = bot.manager?._lastFilledPrice;
|
|
2925
|
+
const sumType = bot.manager?._lastFilledType;
|
|
2926
|
+
const sumInc = resolveLastFillGuardIncrement(bot);
|
|
2927
|
+
const cold = sumPivotRaw == null || !Number.isFinite(Number(sumPivotRaw)) || sumType == null;
|
|
2928
|
+
const batchOrigin = cowResult?.origin;
|
|
2929
|
+
const mode = cold
|
|
2930
|
+
? 'disabled(cold)'
|
|
2931
|
+
: (lastFillGuardStats.bypassed > 0 && lastFillGuardStats.checked === 0)
|
|
2932
|
+
? `bypassed(${batchOrigin || 'unknown'})`
|
|
2933
|
+
: (lastFillGuardStats.bypassed > 0
|
|
2934
|
+
? `active+bypassed(${batchOrigin || 'unknown'})`
|
|
2935
|
+
: 'active');
|
|
2936
|
+
const pivotStr = cold ? 'none' : `${Format.formatPrice6(Number(sumPivotRaw))}(${sumType})`;
|
|
2937
|
+
bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr} inc=${sumInc}% ` +
|
|
2938
|
+
`pivotRefreshed=${lastFillGuardPivotRefreshed} ` +
|
|
2939
|
+
`checked=${lastFillGuardStats.checked} passed=${lastFillGuardStats.passed} ` +
|
|
2940
|
+
`skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}`, cold ? 'warn' : 'info');
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
catch { /* summary is best-effort */ }
|
|
2453
2944
|
if (skippedUpdateCount > 0) {
|
|
2454
2945
|
restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
|
|
2455
2946
|
}
|
|
2947
|
+
if (clampedUpdateSlotIds.size > 0) {
|
|
2948
|
+
const masterVersion = Number.isFinite(Number(bot.manager?._gridVersion))
|
|
2949
|
+
? Number(bot.manager._gridVersion)
|
|
2950
|
+
: undefined;
|
|
2951
|
+
for (const slotId of clampedUpdateSlotIds) {
|
|
2952
|
+
workingGrid.syncFromMaster(bot.manager.orders, slotId, masterVersion);
|
|
2953
|
+
}
|
|
2954
|
+
bot.manager.logger.log(`[COW] Re-synced ${clampedUpdateSlotIds.size} post-fill-clamped slot(s) from master before commit`, 'debug');
|
|
2955
|
+
}
|
|
2456
2956
|
if (operations.length === 0) {
|
|
2457
2957
|
// Pop the working grid: in the re-plan recursion the fresh plan's
|
|
2458
2958
|
// grid was pushed by performSafeRebalance, and nothing downstream
|
|
@@ -2495,7 +2995,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2495
2995
|
// for it to settle, then claim the slot synchronously (no await
|
|
2496
2996
|
// between the check inside waitForCowBroadcastSingleFlight and the
|
|
2497
2997
|
// assignment below), so two batches can never broadcast together.
|
|
2498
|
-
await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast')
|
|
2998
|
+
if (await waitForCowBroadcastSingleFlight(bot, 'pre-broadcast')) {
|
|
2999
|
+
popPushedWorkingGrid(bot, cowResult);
|
|
3000
|
+
return { executed: false, aborted: true, reason: 'SHUTDOWN_IN_PROGRESS', hadRotation: false };
|
|
3001
|
+
}
|
|
2499
3002
|
bot._cowBroadcastInFlight = true;
|
|
2500
3003
|
heldBroadcastSlot = true;
|
|
2501
3004
|
await bot._ensureCredentialDaemonWritable('COW batch broadcast');
|
|
@@ -2514,19 +3017,74 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2514
3017
|
const missingSlots = missingCreateResults
|
|
2515
3018
|
.map((item) => item.ctx?.order?.id || item.ctx?.id || `op-${item.index}`)
|
|
2516
3019
|
.join(', ');
|
|
2517
|
-
bot.manager.logger.log(`[COW]
|
|
2518
|
-
`
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
3020
|
+
bot.manager.logger.log(`[COW] ${missingCreateResults.length} CREATE op(s) returned no chainOrderId ` +
|
|
3021
|
+
`(${missingSlots}). Resolving from chain before commit (normalize, don't reject).`, 'warn');
|
|
3022
|
+
// Layer 2 (normalize, don't reject + uncertain-broadcast routing):
|
|
3023
|
+
// the batch otherwise succeeded, so a missing id is the
|
|
3024
|
+
// "broadcast succeeded but attach lost" ambiguous case. Poll the
|
|
3025
|
+
// chain to separate REAL on-chain orders (adopt their id into the
|
|
3026
|
+
// working grid so they commit normally) from PHANTOMS (normalize the
|
|
3027
|
+
// slot in place to a clean empty). Either way the REST of the batch
|
|
3028
|
+
// commits — we never discard a whole batch over one missing id.
|
|
3029
|
+
const confirmation = await pollChainForConfirmation(bot, missingCreateResults.map((m) => m.ctx));
|
|
3030
|
+
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
3031
|
+
let chainSnap = null;
|
|
3032
|
+
try {
|
|
3033
|
+
const cr = await readOpenOrdersWithMetaSafe(chainOrders, accountRef);
|
|
3034
|
+
if (cr && !cr.truncated)
|
|
3035
|
+
chainSnap = cr.orders;
|
|
3036
|
+
}
|
|
3037
|
+
catch { /* best-effort; confirmed set already known from poll */ }
|
|
3038
|
+
let adoptedCount = 0;
|
|
3039
|
+
let normalizedCount = 0;
|
|
3040
|
+
for (const item of missingCreateResults) {
|
|
3041
|
+
const slotId = item.ctx?.order?.id || item.ctx?.id;
|
|
3042
|
+
const slot = workingGrid.get(slotId);
|
|
3043
|
+
if (!slot)
|
|
3044
|
+
continue;
|
|
3045
|
+
const isConfirmed = confirmation.confirmed.some((c) => (c?.order?.id || c?.id) === slotId);
|
|
3046
|
+
if (isConfirmed && chainSnap) {
|
|
3047
|
+
const match = findChainOrderForSlot(bot, chainSnap, slotId, {
|
|
3048
|
+
sell: item.ctx?.finalInts?.sell,
|
|
3049
|
+
receive: item.ctx?.finalInts?.receive,
|
|
3050
|
+
orderType: item.ctx?.order?.type,
|
|
3051
|
+
fingerprint: buildCreateOpFingerprint({
|
|
3052
|
+
side: item.ctx?.order?.type,
|
|
3053
|
+
assetA: bot.manager?.assets?.assetA?.id,
|
|
3054
|
+
assetB: bot.manager?.assets?.assetB?.id,
|
|
3055
|
+
sellInt: item.ctx?.finalInts?.sell,
|
|
3056
|
+
receiveInt: item.ctx?.finalInts?.receive,
|
|
3057
|
+
slotId
|
|
3058
|
+
})
|
|
3059
|
+
});
|
|
3060
|
+
if (match?.id) {
|
|
3061
|
+
workingGrid.set(slotId, { ...slot, orderId: match.id });
|
|
3062
|
+
adoptedCount++;
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
// Normalize: drop the size so the slot cannot persist as a
|
|
3067
|
+
// phantom placed order (VIRTUAL + size>0 + no orderId) — the
|
|
3068
|
+
// exact corrupt shape that recurs as a sized orphan. It commits
|
|
3069
|
+
// as a clean empty and is re-placed by the next cycle / spread
|
|
3070
|
+
// correction.
|
|
3071
|
+
workingGrid.set(slotId, {
|
|
3072
|
+
...slot,
|
|
3073
|
+
size: 0,
|
|
3074
|
+
orderId: null,
|
|
3075
|
+
state: ORDER_STATES.VIRTUAL
|
|
3076
|
+
});
|
|
3077
|
+
normalizedCount++;
|
|
3078
|
+
}
|
|
3079
|
+
bot.manager.logger.log(`[COW] Missing CREATEs resolved: ${adoptedCount} adopted from chain, ` +
|
|
3080
|
+
`${normalizedCount} normalized in place; committing remainder of batch.`, 'warn');
|
|
3081
|
+
// NOTE: we intentionally do NOT mark these as structural blockers.
|
|
3082
|
+
// Normalizing the slot to a clean empty lets the next cycle re-create
|
|
3083
|
+
// it, and the regular open-orders sync adopts any order that really
|
|
3084
|
+
// did land on chain — so a permanent block (which would reject every
|
|
3085
|
+
// subsequent CREATE batch) is avoided.
|
|
3086
|
+
// DO NOT pop/discard the working grid — fall through to commit the
|
|
3087
|
+
// rest of the batch below.
|
|
2530
3088
|
}
|
|
2531
3089
|
// Pre-apply rotation state transitions to the working grid so the
|
|
2532
3090
|
// COW commit is truly atomic for structural changes (source → VIRTUAL,
|
|
@@ -2547,7 +3105,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2547
3105
|
// is on chain; adopt the placed orders from the chain so master
|
|
2548
3106
|
// converges instead of remaining divergent until a later sync.
|
|
2549
3107
|
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]');
|
|
3108
|
+
const adopted = await adoptPlacedBatchFromChain(bot, chainOrders, '[COW]', { placedResults: result, placedContexts: executedContexts });
|
|
2551
3109
|
if (!adopted) {
|
|
2552
3110
|
// Chain state unknown (empty/lagging read or sync failure):
|
|
2553
3111
|
// keep the pending-broadcast protection so a later plan
|
|
@@ -2560,6 +3118,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2560
3118
|
// Deduct create fees for the placed orders (mirrors
|
|
2561
3119
|
// processBatchResults, which the refused path bypasses).
|
|
2562
3120
|
await applyAdoptionFeeAccounting(bot, executedContexts);
|
|
3121
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
2563
3122
|
await persistGridAndClearPendingBroadcasts(bot, '[COW]');
|
|
2564
3123
|
return { executed: false, hadRotation: false, commitRefused: true };
|
|
2565
3124
|
}
|
|
@@ -2657,6 +3216,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2657
3216
|
return { executed: false, hadRotation: false, commitRefused: true, chainAdoptionPending: true };
|
|
2658
3217
|
}
|
|
2659
3218
|
await applyAdoptionFeeAccounting(bot, opContexts);
|
|
3219
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
2660
3220
|
await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
|
|
2661
3221
|
return { executed: false, hadRotation: false, commitRefused: true, uncertainResolved: true };
|
|
2662
3222
|
}
|
|
@@ -2679,6 +3239,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2679
3239
|
// result to extract); deduct create fees so the optimistic
|
|
2680
3240
|
// balance reflects the on-chain cost.
|
|
2681
3241
|
await applyAdoptionFeeAccounting(bot, opContexts);
|
|
3242
|
+
await restoreBoundaryAfterAdoption(bot, workingBoundary);
|
|
2682
3243
|
await persistGridAndClearPendingBroadcasts(bot, '[COW][UNCERTAIN]');
|
|
2683
3244
|
return { executed: true, hadRotation: false, uncertainResolved: true };
|
|
2684
3245
|
}
|
|
@@ -2755,16 +3316,193 @@ async function requestStructuralResync(bot, reason, details = {}) {
|
|
|
2755
3316
|
* @param {string} logPrefix - Log prefix for sync failure messages
|
|
2756
3317
|
* @returns {Promise<boolean>}
|
|
2757
3318
|
*/
|
|
2758
|
-
|
|
3319
|
+
/**
|
|
3320
|
+
* Collect every on-chain order id master currently needs to converge against,
|
|
3321
|
+
* so adoption can re-read them by id (immune to the get_full_accounts window
|
|
3322
|
+
* truncation) instead of relying on a partial window read.
|
|
3323
|
+
*
|
|
3324
|
+
* Sources:
|
|
3325
|
+
* - master's own tracked order ids (existing on-chain orders);
|
|
3326
|
+
* - the batch's fresh CREATE ids extracted from the broadcast result
|
|
3327
|
+
* (operation_results[i][1] aligns positionally with placedContexts[i]).
|
|
3328
|
+
*
|
|
3329
|
+
* @param {any} mgr - bot.manager
|
|
3330
|
+
* @param {any} placedResults - broadcast result (has operation_results); null when unavailable
|
|
3331
|
+
* @param {any[]} placedContexts - opContexts (aligned with operation_results); null when unavailable
|
|
3332
|
+
* @returns {string[]} Unique, well-formed 1.7.x order ids
|
|
3333
|
+
*/
|
|
3334
|
+
function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts) {
|
|
3335
|
+
const masterIds = new Set();
|
|
3336
|
+
const grid = mgr && mgr.grid;
|
|
3337
|
+
if (Array.isArray(grid)) {
|
|
3338
|
+
for (const slot of grid) {
|
|
3339
|
+
if (slot && slot.orderId && /^1\.7\.\d+$/.test(String(slot.orderId))) {
|
|
3340
|
+
masterIds.add(String(slot.orderId));
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
const createIds = new Set();
|
|
3345
|
+
if (placedResults && Array.isArray(placedContexts)) {
|
|
3346
|
+
const opResults = extractBatchOperationResults(placedResults);
|
|
3347
|
+
if (Array.isArray(opResults)) {
|
|
3348
|
+
for (let i = 0; i < placedContexts.length; i++) {
|
|
3349
|
+
const ctx = placedContexts[i];
|
|
3350
|
+
if (!ctx || ctx.kind !== 'create')
|
|
3351
|
+
continue;
|
|
3352
|
+
const opResult = opResults[i] && opResults[i][1];
|
|
3353
|
+
if (opResult && /^1\.7\.\d+$/.test(String(opResult))) {
|
|
3354
|
+
createIds.add(String(opResult));
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
const all = new Set([...masterIds, ...createIds]);
|
|
3360
|
+
return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
|
|
3361
|
+
}
|
|
3362
|
+
/**
|
|
3363
|
+
* Converge master with the chain after a COW commit was refused (master moved
|
|
3364
|
+
* during broadcast) or an uncertain broadcast was poll-confirmed.
|
|
3365
|
+
*
|
|
3366
|
+
* Preferred path (when the broadcast result is available): re-read the exact
|
|
3367
|
+
* on-chain orders BY ID — every id master already tracks plus the batch's
|
|
3368
|
+
* fresh CREATE ids. get_objects returns the complete, authoritative set
|
|
3369
|
+
* regardless of account size, so syncFromOpenOrders converges master instead
|
|
3370
|
+
* of dropping the freshest creates (which previously left permanent orphans
|
|
3371
|
+
* and tripped the fund invariant). This is fully immune to the
|
|
3372
|
+
* get_full_accounts window truncation that broke the old window-read path.
|
|
3373
|
+
*
|
|
3374
|
+
* Fallback (no broadcast result, e.g. uncertain paths, or by-id read
|
|
3375
|
+
* unavailable): window read. A truncated read is ambiguous — only the
|
|
3376
|
+
* freshest orders are dropped — so it MUST NOT drive adoption; we return false
|
|
3377
|
+
* and let the caller keep pending-broadcast protection + structural resync.
|
|
3378
|
+
*
|
|
3379
|
+
* @param {any} bot
|
|
3380
|
+
* @param {any} chainOrders - chain_orders module (has readOpenOrdersWithMetaSafe + batchReadOrders)
|
|
3381
|
+
* @param {string} logPrefix
|
|
3382
|
+
* @param {Object} [opts]
|
|
3383
|
+
* @param {any} [opts.placedResults] - broadcast result carrying operation_results
|
|
3384
|
+
* @param {any[]} [opts.placedContexts] - opContexts aligned with operation_results
|
|
3385
|
+
* @returns {Promise<boolean>} true if master was adopted from the chain
|
|
3386
|
+
*/
|
|
3387
|
+
/**
|
|
3388
|
+
* P1-atomic: after a refused/uncertain COW commit is recovered by adopting the
|
|
3389
|
+
* placed orders from the chain, re-apply the rotational boundary this batch had
|
|
3390
|
+
* already computed (workingBoundary) to master. The refused commit discarded
|
|
3391
|
+
* the working grid, so without this the next rebalance would re-derive the
|
|
3392
|
+
* boundary from a master that still reflects the pre-fill layout and could
|
|
3393
|
+
* re-stamp the just-filled slot x. Applying workingBoundary commits the
|
|
3394
|
+
* post-fill rotation immediately (no re-broadcast), so the next placement lands
|
|
3395
|
+
* at the shifted slot, never at x.
|
|
3396
|
+
*
|
|
3397
|
+
* @param {any} bot
|
|
3398
|
+
* @param {number} workingBoundary - boundary index the refused batch targeted
|
|
3399
|
+
*/
|
|
3400
|
+
async function restoreBoundaryAfterAdoption(bot, workingBoundary) {
|
|
2759
3401
|
try {
|
|
3402
|
+
if (workingBoundary === undefined || workingBoundary === null)
|
|
3403
|
+
return;
|
|
3404
|
+
if (typeof bot.manager._restoreBoundary === 'function') {
|
|
3405
|
+
bot.manager._restoreBoundary(workingBoundary);
|
|
3406
|
+
}
|
|
3407
|
+
else {
|
|
3408
|
+
bot.manager.boundaryIdx = workingBoundary;
|
|
3409
|
+
}
|
|
3410
|
+
bot.manager.logger.log(`[COW] Restored rotational boundary ${workingBoundary} after refused/uncertain commit + chain adoption (atomic re-plan)`, 'info');
|
|
3411
|
+
}
|
|
3412
|
+
catch (e) {
|
|
3413
|
+
bot.manager.logger.log(`[COW] Post-adoption boundary restore failed: ${getErrorMessage(e)}`, 'warn');
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
async function adoptPlacedBatchFromChain(bot, chainOrders, logPrefix, opts = {}) {
|
|
3417
|
+
const { placedResults = null, placedContexts = null } = opts || {};
|
|
3418
|
+
try {
|
|
3419
|
+
const mgr = bot.manager;
|
|
2760
3420
|
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
3421
|
+
// PREFERRED: re-read the exact placed/existing orders by id. Only when we
|
|
3422
|
+
// have the broadcast result (so the set includes the freshest CREATE ids);
|
|
3423
|
+
// without it the by-id set would be incomplete and would wrongly sync
|
|
3424
|
+
// master against a partial picture.
|
|
3425
|
+
const { all: knownIds, createIds } = placedResults
|
|
3426
|
+
? collectKnownOnChainOrderIds(mgr, placedResults, placedContexts)
|
|
3427
|
+
: { all: [], createIds: [] };
|
|
3428
|
+
if (knownIds.length > 0 && typeof chainOrders.batchReadOrders === 'function') {
|
|
3429
|
+
// Retry/backoff (fix #6): a fresh CREATE absent from the first read
|
|
3430
|
+
// is a lagging node, not a missing order; a read error is equally
|
|
3431
|
+
// transient. Retry with backoff before deferring to the structural
|
|
3432
|
+
// resync path — deferral blocks CREATEs for minutes.
|
|
3433
|
+
const maxAttempts = Math.max(1, Number(COW_PERFORMANCE.ADOPTION_READ_MAX_ATTEMPTS) || 3);
|
|
3434
|
+
const baseBackoff = Math.max(250, Number(COW_PERFORMANCE.ADOPTION_READ_BACKOFF_MS) || 2000);
|
|
3435
|
+
let chainMap = null;
|
|
3436
|
+
let laggingCreateIds = [];
|
|
3437
|
+
let lastReadError = null;
|
|
3438
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3439
|
+
chainMap = null;
|
|
3440
|
+
laggingCreateIds = [];
|
|
3441
|
+
lastReadError = null;
|
|
3442
|
+
try {
|
|
3443
|
+
chainMap = await chainOrders.batchReadOrders(knownIds);
|
|
3444
|
+
}
|
|
3445
|
+
catch (byIdErr) {
|
|
3446
|
+
// A by-id read failure must NOT fall through to the window read
|
|
3447
|
+
// (which would also miss the freshest creates and virtualize them).
|
|
3448
|
+
lastReadError = getErrorMessage(byIdErr);
|
|
3449
|
+
}
|
|
3450
|
+
if (chainMap) {
|
|
3451
|
+
// Lagging-node guard (phantom-virtualization risk): a FRESHLY
|
|
3452
|
+
// BROADCAST create id returning null here almost certainly means the
|
|
3453
|
+
// queried node has not yet indexed the order (it was just placed), not
|
|
3454
|
+
// that it is gone. If we synced a partial set, syncFromOpenOrders'
|
|
3455
|
+
// phantom-cleanup would virtualize that live order and count it as a
|
|
3456
|
+
// fill, re-creating a duplicate on the next cycle — the exact orphan
|
|
3457
|
+
// class this path exists to prevent.
|
|
3458
|
+
for (const id of createIds) {
|
|
3459
|
+
if (chainMap.get(id) == null)
|
|
3460
|
+
laggingCreateIds.push(id);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
if (chainMap && laggingCreateIds.length === 0)
|
|
3464
|
+
break;
|
|
3465
|
+
if (attempt < maxAttempts) {
|
|
3466
|
+
const wait = baseBackoff * Math.pow(2, attempt - 1);
|
|
3467
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption read retry ${attempt}/${maxAttempts - 1} in ${wait}ms ` +
|
|
3468
|
+
`(${chainMap ? `fresh CREATE(s) absent: ${laggingCreateIds.join(', ')}` : `read failed: ${lastReadError}`})`, 'warn');
|
|
3469
|
+
await sleep(wait);
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
if (!chainMap) {
|
|
3473
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption read failed after ${maxAttempts} attempt(s): ${lastReadError}; deferring (pending-broadcast protection kept)`, 'warn');
|
|
3474
|
+
return false;
|
|
3475
|
+
}
|
|
3476
|
+
if (laggingCreateIds.length > 0) {
|
|
3477
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption deferred: fresh CREATE(s) ${laggingCreateIds.join(', ')} absent from ` +
|
|
3478
|
+
`${maxAttempts} chain read(s) (likely lagging node). ` +
|
|
3479
|
+
'Keeping pending-broadcast protection pending a caught-up read.', 'error');
|
|
3480
|
+
return false;
|
|
3481
|
+
}
|
|
3482
|
+
const fullChain = [];
|
|
3483
|
+
if (chainMap && typeof chainMap.forEach === 'function') {
|
|
3484
|
+
chainMap.forEach((order) => { if (order)
|
|
3485
|
+
fullChain.push(order); });
|
|
3486
|
+
}
|
|
3487
|
+
else if (Array.isArray(chainMap)) {
|
|
3488
|
+
for (const o of chainMap)
|
|
3489
|
+
if (o)
|
|
3490
|
+
fullChain.push(o);
|
|
3491
|
+
}
|
|
3492
|
+
// Informational: any other known id (master's pre-existing orders)
|
|
3493
|
+
// absent is expected — those were cancelled/filled in this batch.
|
|
3494
|
+
if (fullChain.length < knownIds.length) {
|
|
3495
|
+
bot.manager.logger.log(`${logPrefix} By-id adoption: ${knownIds.length - fullChain.length}/${knownIds.length} known id(s) absent ` +
|
|
3496
|
+
'(expected cancels/fills); adopting the rest', 'debug');
|
|
3497
|
+
}
|
|
3498
|
+
if (fullChain.length > 0 && typeof mgr.syncFromOpenOrders === 'function') {
|
|
3499
|
+
await mgr.syncFromOpenOrders(fullChain, { skipAccounting: false });
|
|
3500
|
+
bot.manager.logger.log(`${logPrefix} Adopted ${fullChain.length} on-chain order(s) by id after refused/uncertain commit (truncation-immune)`, 'info');
|
|
3501
|
+
return true;
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
// FALLBACK: window read (ambiguous when truncated).
|
|
2761
3505
|
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
3506
|
if (freshRead.truncated) {
|
|
2769
3507
|
bot.manager.logger.log(`${logPrefix} Chain read TRUNCATED after batch broadcast; adoption deferred (pending-broadcast protection kept)`, 'warn');
|
|
2770
3508
|
return false;
|
|
@@ -2950,7 +3688,12 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
2950
3688
|
});
|
|
2951
3689
|
}
|
|
2952
3690
|
}
|
|
2953
|
-
|
|
3691
|
+
// Success line mirrors the failure-path fingerprint below
|
|
3692
|
+
// (type/price/size) so both are greppable by the same keys —
|
|
3693
|
+
// this is what ties a fill back to the slot that placed it.
|
|
3694
|
+
bot.manager.logger.log(`Placed ${ctx.order.type} order ${ctx.order.id} ` +
|
|
3695
|
+
`@${Format.formatPrice6(ctx.order.price)} x${Format.formatAmount(ctx.order.size)} ` +
|
|
3696
|
+
`-> ${chainOrderId}`, 'info');
|
|
2954
3697
|
}
|
|
2955
3698
|
else {
|
|
2956
3699
|
const fingerprint = [
|
|
@@ -3051,14 +3794,12 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
3051
3794
|
updateOperationCount
|
|
3052
3795
|
};
|
|
3053
3796
|
}
|
|
3054
|
-
export { buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts,
|
|
3797
|
+
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 };
|
|
3055
3798
|
export default {
|
|
3056
3799
|
buildOutsideInPairGroupsForOrders,
|
|
3057
3800
|
buildOutsideInPairGroupsForCreateEntries,
|
|
3058
3801
|
extractOperationResults,
|
|
3059
3802
|
findMissingCreateResultContexts,
|
|
3060
|
-
recoverAfterMissingCreateResults,
|
|
3061
|
-
preserveMissingCreateBlockersAfterRecovery,
|
|
3062
3803
|
markMissingCreateResultsAsStructuralBlocker,
|
|
3063
3804
|
formatUnmatchedChainOrderForLog,
|
|
3064
3805
|
recordPendingBroadcast,
|