dexbot 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/credential-daemon.d.ts.map +1 -1
- package/dist/credential-daemon.js +53 -13
- package/dist/credential-daemon.js.map +1 -1
- package/dist/modules/bitshares-native/serial/types.js +1 -1
- package/dist/modules/bitshares-native/serial/types.js.map +1 -1
- package/dist/modules/chain_keys.js +1 -1
- package/dist/modules/chain_keys.js.map +1 -1
- package/dist/modules/constants.d.ts +2 -0
- package/dist/modules/constants.d.ts.map +1 -1
- package/dist/modules/constants.js +10 -0
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/credential_policy.js +1 -1
- package/dist/modules/credential_policy.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +45 -175
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +84 -3496
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +319 -0
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -0
- package/dist/modules/dexbot_cow_runtime.js +1764 -0
- package/dist/modules/dexbot_cow_runtime.js.map +1 -0
- package/dist/modules/dexbot_fill_runtime.d.ts +47 -0
- package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_fill_runtime.js +607 -3
- package/dist/modules/dexbot_fill_runtime.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts +69 -0
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +244 -15
- package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
- package/dist/modules/dexbot_startup_runtime.d.ts +30 -0
- package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -0
- package/dist/modules/dexbot_startup_runtime.js +539 -0
- package/dist/modules/dexbot_startup_runtime.js.map +1 -0
- package/dist/modules/dexbot_state_recovery.d.ts +128 -0
- package/dist/modules/dexbot_state_recovery.d.ts.map +1 -0
- package/dist/modules/dexbot_state_recovery.js +397 -0
- package/dist/modules/dexbot_state_recovery.js.map +1 -0
- package/dist/modules/fund_registry.d.ts.map +1 -1
- package/dist/modules/fund_registry.js +0 -1
- package/dist/modules/fund_registry.js.map +1 -1
- package/dist/modules/general_settings.d.ts.map +1 -1
- package/dist/modules/general_settings.js +0 -1
- package/dist/modules/general_settings.js.map +1 -1
- package/dist/modules/key_store.d.ts.map +1 -1
- package/dist/modules/key_store.js +1 -2
- package/dist/modules/key_store.js.map +1 -1
- package/dist/modules/launcher/market_adapter_runtime.d.ts.map +1 -1
- package/dist/modules/launcher/market_adapter_runtime.js +0 -1
- package/dist/modules/launcher/market_adapter_runtime.js.map +1 -1
- package/dist/modules/launcher/market_adapter_watchdog.d.ts.map +1 -1
- package/dist/modules/launcher/market_adapter_watchdog.js +1 -2
- package/dist/modules/launcher/market_adapter_watchdog.js.map +1 -1
- package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile.js +0 -1
- package/dist/modules/order/grid_reconcile.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.js +2 -2
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
- package/dist/modules/order/utils/math.js +1 -1
- package/dist/modules/order/utils/math.js.map +1 -1
- package/dist/modules/order/utils/system.js +1 -1
- package/dist/modules/order/utils/system.js.map +1 -1
- package/dist/modules/order/utils/validate.d.ts.map +1 -1
- package/dist/modules/order/utils/validate.js +0 -1
- package/dist/modules/order/utils/validate.js.map +1 -1
- package/dist/modules/paths.d.ts +2 -0
- package/dist/modules/paths.d.ts.map +1 -1
- package/dist/modules/paths.js +13 -2
- package/dist/modules/paths.js.map +1 -1
- package/dist/scripts/verify-browser-bundle.js +25 -1
- package/dist/scripts/verify-browser-bundle.js.map +1 -1
- package/package.json +7 -2
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* modules/dexbot_cow_runtime.ts - COW (Copy-on-Write) Batch Execution Runtime
|
|
4
|
+
*
|
|
5
|
+
* Handles on-chain order execution with Copy-on-Write working grid semantics,
|
|
6
|
+
* broadcast uncertainty recovery, and result processing.
|
|
7
|
+
*
|
|
8
|
+
* Each function takes the DEXBot instance as its first parameter, following
|
|
9
|
+
* the same pattern as dexbot_fill_runtime.ts.
|
|
10
|
+
*/
|
|
11
|
+
const chainOrders = require('./chain_orders');
|
|
12
|
+
const { BroadcastUncertainError } = require('./dexbot_credential_client');
|
|
13
|
+
const { buildCreateOrderArgs, buildCreateOpFingerprint, extractBatchOperationResults, formatUnmatchedChainOrder, convertToSpreadPlaceholder, buildOutsideInPairGroups, } = require('./order/utils/order');
|
|
14
|
+
const { validateCreateTargetSlots } = require('./order/utils/validate');
|
|
15
|
+
const { validateOrderSize } = require('./order/utils/math');
|
|
16
|
+
// Lazy accessor so test mocks on the math module export take effect at call time.
|
|
17
|
+
function getAssetFees(...args) { return require('./order/utils/math').getAssetFees(...args); }
|
|
18
|
+
const { COW_ACTIONS, ORDER_STATES, ORDER_TYPES, REBALANCE_STATES, FILL_PROCESSING, } = require('./constants');
|
|
19
|
+
const Format = require('./order/format');
|
|
20
|
+
const { WorkingGrid } = require('./order/working_grid');
|
|
21
|
+
/**
|
|
22
|
+
* Group orders into outside-in pairs for atomic create execution.
|
|
23
|
+
* @param {Array} orders
|
|
24
|
+
* @returns {Array<Array>}
|
|
25
|
+
*/
|
|
26
|
+
function buildOutsideInPairGroupsForOrders(orders) {
|
|
27
|
+
return buildOutsideInPairGroups(orders, {
|
|
28
|
+
isValid: Boolean,
|
|
29
|
+
getType: o => o.type,
|
|
30
|
+
getPrice: o => o.price,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build outside-in pair groups for create entry contexts.
|
|
35
|
+
* @param {Array} createEntries
|
|
36
|
+
* @returns {Array<Array>}
|
|
37
|
+
*/
|
|
38
|
+
function buildOutsideInPairGroupsForCreateEntries(createEntries) {
|
|
39
|
+
return buildOutsideInPairGroups(createEntries, {
|
|
40
|
+
isValid: e => Boolean(e?.context?.order),
|
|
41
|
+
getType: e => e.context.order.type,
|
|
42
|
+
getPrice: e => e.context.order.price,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Extract operation results from a batch transaction result.
|
|
47
|
+
* @param {Object|Array|null} result
|
|
48
|
+
* @param {string} [warnContext='']
|
|
49
|
+
* @param {Function} [logFn] - Optional logger function, called with (msg, level) on unrecognized shape
|
|
50
|
+
* @returns {Array}
|
|
51
|
+
*/
|
|
52
|
+
function extractOperationResults(result, warnContext = '', logFn = null) {
|
|
53
|
+
const extracted = extractBatchOperationResults(result);
|
|
54
|
+
if (Array.isArray(extracted))
|
|
55
|
+
return extracted;
|
|
56
|
+
if (result && logFn) {
|
|
57
|
+
const resultType = Array.isArray(result) ? 'array' : typeof result;
|
|
58
|
+
const keySummary = (resultType === 'object' && !Array.isArray(result))
|
|
59
|
+
? Object.keys(result).slice(0, 8).join(',')
|
|
60
|
+
: '';
|
|
61
|
+
const contextSuffix = warnContext ? ` (${warnContext})` : '';
|
|
62
|
+
const keysSuffix = keySummary ? `; keys=[${keySummary}]` : '';
|
|
63
|
+
logFn(`[COW] Unrecognized operation_results shape${contextSuffix}; defaulting to empty results. resultType=${resultType}${keysSuffix}`, 'warn');
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Find CREATE operation contexts whose broadcast result did not include a chain order id.
|
|
69
|
+
* @param {Array} operationResults
|
|
70
|
+
* @param {Array} opContexts
|
|
71
|
+
* @returns {Array<{index:number, ctx:Object}>}
|
|
72
|
+
*/
|
|
73
|
+
function findMissingCreateResultContexts(operationResults, opContexts) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
if (!Array.isArray(opContexts))
|
|
76
|
+
return missing;
|
|
77
|
+
for (let i = 0; i < opContexts.length; i++) {
|
|
78
|
+
const ctx = opContexts[i];
|
|
79
|
+
if (ctx?.kind !== 'create')
|
|
80
|
+
continue;
|
|
81
|
+
const chainOrderId = operationResults?.[i]?.[1];
|
|
82
|
+
if (!chainOrderId || !/^1\.7\.\d+$/.test(String(chainOrderId))) {
|
|
83
|
+
missing.push({ index: i, ctx });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return missing;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Run an immediate chain sync after a successful CREATE broadcast returned incomplete ids.
|
|
90
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
91
|
+
* @param {string} [reason]
|
|
92
|
+
* @returns {Promise<void>}
|
|
93
|
+
*/
|
|
94
|
+
async function recoverAfterMissingCreateResults(bot, reason = 'missing create operation results') {
|
|
95
|
+
try {
|
|
96
|
+
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
97
|
+
if (!accountRef || !bot.manager || !chainOrders?.readOpenOrders) {
|
|
98
|
+
bot.manager?.logger?.log?.(`[COW] Recovery sync unavailable after ${reason}`, 'warn');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const preRecoveryMissingCreateBlockers = Array.isArray(bot.manager._lastUnmatchedChainOrders)
|
|
102
|
+
? bot.manager._lastUnmatchedChainOrders
|
|
103
|
+
.filter(order => order?.reason === 'missing-create-result')
|
|
104
|
+
.map(order => ({ ...order }))
|
|
105
|
+
: [];
|
|
106
|
+
const openOrders = await chainOrders.readOpenOrders(accountRef);
|
|
107
|
+
const recoveryResult = await bot.manager.syncFromOpenOrders(openOrders, {
|
|
108
|
+
skipAccounting: false,
|
|
109
|
+
});
|
|
110
|
+
preserveMissingCreateBlockersAfterRecovery(bot, preRecoveryMissingCreateBlockers, recoveryResult);
|
|
111
|
+
if (typeof bot.manager.persistGrid === 'function') {
|
|
112
|
+
await bot.manager.persistGrid();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
bot.manager?.logger?.log?.(`[COW] CRITICAL: Recovery sync failed after ${reason}: ${err.message}`, 'error');
|
|
117
|
+
if (typeof bot.manager?.requestStructuralGridResync === 'function') {
|
|
118
|
+
try {
|
|
119
|
+
await bot.manager.requestStructuralGridResync(`recovery sync failed after ${reason}`, {
|
|
120
|
+
error: err.message
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (scheduleErr) {
|
|
124
|
+
bot.manager?.logger?.log?.(`[COW] CRITICAL: Failed to schedule structural resync after recovery failure: ${scheduleErr.message}`, 'error');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Restore unresolved missing-create blockers after recovery if sync did not adopt them.
|
|
131
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
132
|
+
* @param {Array} blockers
|
|
133
|
+
* @param {Object} recoveryResult
|
|
134
|
+
*/
|
|
135
|
+
function preserveMissingCreateBlockersAfterRecovery(bot, blockers, recoveryResult) {
|
|
136
|
+
if (!Array.isArray(blockers) || blockers.length === 0 || !bot.manager)
|
|
137
|
+
return;
|
|
138
|
+
const adoptedSlotIds = new Set((Array.isArray(recoveryResult?.updatedOrders) ? recoveryResult.updatedOrders : [])
|
|
139
|
+
.filter(order => order?.id && order?.orderId)
|
|
140
|
+
.map(order => order.id));
|
|
141
|
+
const unresolvedBlockers = blockers.filter(blocker => !blocker.slotId || !adoptedSlotIds.has(blocker.slotId));
|
|
142
|
+
if (unresolvedBlockers.length === 0)
|
|
143
|
+
return;
|
|
144
|
+
const currentUnmatched = Array.isArray(bot.manager._lastUnmatchedChainOrders)
|
|
145
|
+
? bot.manager._lastUnmatchedChainOrders
|
|
146
|
+
: [];
|
|
147
|
+
const currentKeys = new Set(currentUnmatched.map(order => `${order.reason || ''}:${order.slotId || ''}:${order.operationIndex ?? ''}`));
|
|
148
|
+
const restored = [...currentUnmatched];
|
|
149
|
+
for (const blocker of unresolvedBlockers) {
|
|
150
|
+
const key = `${blocker.reason || ''}:${blocker.slotId || ''}:${blocker.operationIndex ?? ''}`;
|
|
151
|
+
if (!currentKeys.has(key))
|
|
152
|
+
restored.push({ ...blocker });
|
|
153
|
+
}
|
|
154
|
+
if (restored.length !== currentUnmatched.length) {
|
|
155
|
+
bot.manager._lastUnmatchedChainOrders = restored;
|
|
156
|
+
bot.manager._lastUnmatchedChainOrdersAt = Date.now();
|
|
157
|
+
bot.manager.logger?.log?.(`[COW] Preserving ${restored.length - currentUnmatched.length} missing-create blocker(s) after recovery sync; ` +
|
|
158
|
+
`chain snapshot did not account for the affected slot(s).`, 'warn');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Merge missing CREATE result contexts into manager._lastUnmatchedChainOrders.
|
|
163
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
164
|
+
* @param {Array<{index:number, ctx:Object}>} missingCreateResults
|
|
165
|
+
*/
|
|
166
|
+
function markMissingCreateResultsAsStructuralBlocker(bot, missingCreateResults) {
|
|
167
|
+
const blockers = Array.isArray(missingCreateResults)
|
|
168
|
+
? missingCreateResults.map(item => {
|
|
169
|
+
const order = item.ctx?.order || {};
|
|
170
|
+
const fingerprint = [
|
|
171
|
+
`type=${order.type || 'unknown'}`,
|
|
172
|
+
`price=${Format.formatPrice6(order.price)}`,
|
|
173
|
+
`size=${Format.formatAmount(order.size)}`
|
|
174
|
+
].join(',');
|
|
175
|
+
return {
|
|
176
|
+
chainOrderId: 'unknown',
|
|
177
|
+
type: order.type || null,
|
|
178
|
+
price: order.price,
|
|
179
|
+
size: order.size,
|
|
180
|
+
slotId: order.id || item.ctx?.id || null,
|
|
181
|
+
reason: 'missing-create-result',
|
|
182
|
+
operationIndex: item.index,
|
|
183
|
+
fingerprint,
|
|
184
|
+
};
|
|
185
|
+
})
|
|
186
|
+
: [];
|
|
187
|
+
if (bot.manager && blockers.length > 0) {
|
|
188
|
+
const existing = Array.isArray(bot.manager._lastUnmatchedChainOrders)
|
|
189
|
+
? bot.manager._lastUnmatchedChainOrders
|
|
190
|
+
: [];
|
|
191
|
+
const keys = new Set(existing.map(order => `${order.reason || ''}:${order.slotId || ''}:${order.operationIndex ?? ''}`));
|
|
192
|
+
const merged = [...existing];
|
|
193
|
+
for (const blocker of blockers) {
|
|
194
|
+
const key = `${blocker.reason || ''}:${blocker.slotId || ''}:${blocker.operationIndex ?? ''}`;
|
|
195
|
+
if (!keys.has(key)) {
|
|
196
|
+
merged.push(blocker);
|
|
197
|
+
keys.add(key);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
bot.manager._lastUnmatchedChainOrders = merged;
|
|
201
|
+
bot.manager._lastUnmatchedChainOrdersAt = Date.now();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Format an unmatched chain order for COW logs.
|
|
206
|
+
* @param {Object} order
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function formatUnmatchedChainOrderForLog(order) {
|
|
210
|
+
return formatUnmatchedChainOrder(order);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Record a pending CREATE broadcast on the manager.
|
|
214
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
215
|
+
* @param {Object} entry
|
|
216
|
+
*/
|
|
217
|
+
function recordPendingBroadcast(bot, entry) {
|
|
218
|
+
if (!bot.manager || !entry || !entry.order)
|
|
219
|
+
return;
|
|
220
|
+
if (!bot.manager._pendingBroadcasts || !(bot.manager._pendingBroadcasts instanceof Map)) {
|
|
221
|
+
bot.manager._pendingBroadcasts = new Map();
|
|
222
|
+
}
|
|
223
|
+
const fingerprint = buildCreateOpFingerprint({
|
|
224
|
+
side: entry.order.type,
|
|
225
|
+
assetA: bot.manager?.assets?.assetA?.id,
|
|
226
|
+
assetB: bot.manager?.assets?.assetB?.id,
|
|
227
|
+
sellInt: entry.finalInts?.sell,
|
|
228
|
+
receiveInt: entry.finalInts?.receive,
|
|
229
|
+
slotId: entry.order.id
|
|
230
|
+
});
|
|
231
|
+
if (!fingerprint) {
|
|
232
|
+
bot.manager.logger.log?.(`[COW] Skipped pending-broadcast record: could not build fingerprint for ${entry.order?.id || 'unknown'}`, 'warn');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
bot.manager._pendingBroadcasts.set(fingerprint, {
|
|
236
|
+
fingerprint,
|
|
237
|
+
opIndex: entry.opIndex,
|
|
238
|
+
ctxIndex: entry.ctxIndex,
|
|
239
|
+
slotId: entry.order.id,
|
|
240
|
+
orderId: entry.order.id,
|
|
241
|
+
orderType: entry.order.type,
|
|
242
|
+
order: entry.order,
|
|
243
|
+
finalInts: entry.finalInts,
|
|
244
|
+
batchId: bot._currentBatchId || null,
|
|
245
|
+
recordedAt: Date.now()
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Clear the pending-broadcast cache.
|
|
250
|
+
* @param {Map} pendingBroadcasts
|
|
251
|
+
*/
|
|
252
|
+
function clearPendingBroadcasts(pendingBroadcasts) {
|
|
253
|
+
if (pendingBroadcasts instanceof Map) {
|
|
254
|
+
pendingBroadcasts.clear();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Build a fingerprint for an on-chain order so it can be matched against
|
|
259
|
+
* the pending-broadcast cache.
|
|
260
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
261
|
+
* @param {Object} chainOrder
|
|
262
|
+
* @param {string} slotId
|
|
263
|
+
* @returns {string|null}
|
|
264
|
+
*/
|
|
265
|
+
function buildChainOrderFingerprint(bot, chainOrder, slotId) {
|
|
266
|
+
if (!chainOrder || !slotId)
|
|
267
|
+
return null;
|
|
268
|
+
const normalized = normalizeChainOrderForPendingMatch(bot, chainOrder);
|
|
269
|
+
if (!normalized)
|
|
270
|
+
return null;
|
|
271
|
+
return buildCreateOpFingerprint({
|
|
272
|
+
side: normalized.side,
|
|
273
|
+
assetA: normalized.assetA,
|
|
274
|
+
assetB: normalized.assetB,
|
|
275
|
+
sellInt: normalized.sellInt,
|
|
276
|
+
receiveInt: normalized.receiveInt,
|
|
277
|
+
slotId
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Normalize raw BitShares limit_order_object data into the integer tuple
|
|
282
|
+
* used by pending-broadcast recovery.
|
|
283
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
284
|
+
* @param {Object} chainOrder
|
|
285
|
+
* @returns {{side: string, assetA: string, assetB: string, sellInt: number, receiveInt: number}|null}
|
|
286
|
+
*/
|
|
287
|
+
function normalizeChainOrderForPendingMatch(bot, chainOrder) {
|
|
288
|
+
if (!chainOrder)
|
|
289
|
+
return null;
|
|
290
|
+
const assetA = bot.manager?.assets?.assetA?.id;
|
|
291
|
+
const assetB = bot.manager?.assets?.assetB?.id;
|
|
292
|
+
if (!assetA || !assetB)
|
|
293
|
+
return null;
|
|
294
|
+
const explicitSide = (chainOrder.type === 'buy' || chainOrder.type === 'sell')
|
|
295
|
+
? chainOrder.type
|
|
296
|
+
: null;
|
|
297
|
+
const explicitSell = chainOrder.sellInt ?? chainOrder.sell;
|
|
298
|
+
const explicitReceive = chainOrder.receiveInt ?? chainOrder.receive;
|
|
299
|
+
if (explicitSide && Number.isFinite(Number(explicitSell)) && Number.isFinite(Number(explicitReceive))) {
|
|
300
|
+
return {
|
|
301
|
+
side: explicitSide,
|
|
302
|
+
assetA,
|
|
303
|
+
assetB,
|
|
304
|
+
sellInt: Number(explicitSell),
|
|
305
|
+
receiveInt: Number(explicitReceive)
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const base = chainOrder.sell_price?.base;
|
|
309
|
+
const quote = chainOrder.sell_price?.quote;
|
|
310
|
+
if (!base || !quote || !base.asset_id || !quote.asset_id)
|
|
311
|
+
return null;
|
|
312
|
+
const baseAmount = Number(base.amount);
|
|
313
|
+
const quoteAmount = Number(quote.amount);
|
|
314
|
+
if (!Number.isFinite(baseAmount) || !Number.isFinite(quoteAmount))
|
|
315
|
+
return null;
|
|
316
|
+
if (base.asset_id === assetA && quote.asset_id === assetB) {
|
|
317
|
+
return { side: 'sell', assetA, assetB, sellInt: baseAmount, receiveInt: quoteAmount };
|
|
318
|
+
}
|
|
319
|
+
if (base.asset_id === assetB && quote.asset_id === assetA) {
|
|
320
|
+
return { side: 'buy', assetA, assetB, sellInt: baseAmount, receiveInt: quoteAmount };
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Find a chain order that matches a planned slot using price+size proximity.
|
|
326
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
327
|
+
* @param {Array} chainOrders - Open chain orders for the account
|
|
328
|
+
* @param {string} slotId - Planned grid slot id
|
|
329
|
+
* @param {Object} planned - { sell, receive, orderType } integers from the planned op
|
|
330
|
+
* @returns {Object|null} Matching chain order, or null
|
|
331
|
+
*/
|
|
332
|
+
function findChainOrderForSlot(bot, chainOrders, slotId, planned) {
|
|
333
|
+
if (!Array.isArray(chainOrders) || !slotId)
|
|
334
|
+
return null;
|
|
335
|
+
const assetA = bot.manager?.assets?.assetA?.id;
|
|
336
|
+
const assetB = bot.manager?.assets?.assetB?.id;
|
|
337
|
+
if (!assetA || !assetB)
|
|
338
|
+
return null;
|
|
339
|
+
// 1. Exact fingerprint match.
|
|
340
|
+
for (const o of chainOrders) {
|
|
341
|
+
const fp = buildChainOrderFingerprint(bot, o, slotId);
|
|
342
|
+
if (fp && bot.manager._pendingBroadcasts?.has(fp)) {
|
|
343
|
+
return o;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!planned || !Number.isFinite(Number(planned.sell)) || !Number.isFinite(Number(planned.receive))) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
// 2. Near match: same side, sell int within 1, receive int within 1% or 2 units.
|
|
350
|
+
const targetSell = Number(planned.sell);
|
|
351
|
+
const targetReceive = Number(planned.receive);
|
|
352
|
+
const plannedSide = planned.orderType ||
|
|
353
|
+
planned.side ||
|
|
354
|
+
bot.manager._pendingBroadcasts?.get?.(planned.fingerprint)?.orderType ||
|
|
355
|
+
bot.manager.orders.get(slotId)?.type;
|
|
356
|
+
if (plannedSide !== 'buy' && plannedSide !== 'sell') {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
let best = null;
|
|
360
|
+
let bestDistance = Infinity;
|
|
361
|
+
for (const o of chainOrders) {
|
|
362
|
+
const normalized = normalizeChainOrderForPendingMatch(bot, o);
|
|
363
|
+
if (!normalized)
|
|
364
|
+
continue;
|
|
365
|
+
if (normalized.side !== plannedSide)
|
|
366
|
+
continue;
|
|
367
|
+
const sell = Number(normalized.sellInt);
|
|
368
|
+
const receive = Number(normalized.receiveInt);
|
|
369
|
+
if (!Number.isFinite(sell) || !Number.isFinite(receive))
|
|
370
|
+
continue;
|
|
371
|
+
const sellDelta = Math.abs(sell - targetSell);
|
|
372
|
+
const receiveDelta = Math.abs(receive - targetReceive);
|
|
373
|
+
const receiveTol = Math.max(2, Math.floor(targetReceive * 0.01));
|
|
374
|
+
if (sellDelta > 1 || receiveDelta > receiveTol)
|
|
375
|
+
continue;
|
|
376
|
+
const distance = sellDelta * 1000 + receiveDelta;
|
|
377
|
+
if (distance < bestDistance) {
|
|
378
|
+
best = o;
|
|
379
|
+
bestDistance = distance;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return best;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Reconcile a broadcast whose chain state is unknown.
|
|
386
|
+
* Thin wrapper that optionally acquires _fillProcessingLock before delegating.
|
|
387
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
388
|
+
* @param {BroadcastUncertainError} err
|
|
389
|
+
* @param {Array<Object>} opContexts
|
|
390
|
+
* @param {Object} [options]
|
|
391
|
+
* @returns {Promise<Object>}
|
|
392
|
+
*/
|
|
393
|
+
async function reconcileAfterUncertainBroadcast(bot, err, opContexts, options = {}) {
|
|
394
|
+
if (bot.manager?._fillProcessingLock &&
|
|
395
|
+
typeof bot.manager._fillProcessingLock.acquire === 'function' &&
|
|
396
|
+
!bot.manager._fillProcessingLock.isReentrant()) {
|
|
397
|
+
return bot.manager._fillProcessingLock.acquire(() => reconcileAfterUncertainBroadcast(bot, err, opContexts, options));
|
|
398
|
+
}
|
|
399
|
+
return reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, options);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Reconcile a broadcast whose chain state is unknown (implementation).
|
|
403
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
404
|
+
* @param {BroadcastUncertainError} err
|
|
405
|
+
* @param {Array<Object>} opContexts
|
|
406
|
+
* @param {Object} options
|
|
407
|
+
* @returns {Promise<Object>}
|
|
408
|
+
*/
|
|
409
|
+
async function reconcileAfterUncertainBroadcastImpl(bot, err, opContexts, options) {
|
|
410
|
+
const startedAt = Date.now();
|
|
411
|
+
const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
|
|
412
|
+
? Array.from(bot.manager._pendingBroadcasts.values())
|
|
413
|
+
: [];
|
|
414
|
+
const createContextCount = opContexts.filter(c => c && c.kind === 'create').length;
|
|
415
|
+
const nonCreateContextCount = opContexts.length - createContextCount;
|
|
416
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] batchId=${err?.batchId || 'n/a'} ops=${opContexts.length} ` +
|
|
417
|
+
`creates=${createContextCount} nonCreates=${nonCreateContextCount} ` +
|
|
418
|
+
`staleSinceMs=${err?.timeoutMs || 'n/a'}. Entering reconcile-then-decide.`, 'warn');
|
|
419
|
+
if (!chainOrders?.readOpenOrders) {
|
|
420
|
+
bot.manager.logger.log('[COW][UNCERTAIN] readOpenOrders unavailable; falling back to structural resync only.', 'error');
|
|
421
|
+
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
422
|
+
await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders unavailable', { batchId: err?.batchId || null });
|
|
423
|
+
}
|
|
424
|
+
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
425
|
+
return { executed: false, hadRotation: false, uncertain: true };
|
|
426
|
+
}
|
|
427
|
+
// 1. Read the chain
|
|
428
|
+
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
429
|
+
let chainSnapshot = [];
|
|
430
|
+
try {
|
|
431
|
+
chainSnapshot = await chainOrders.readOpenOrders(accountRef);
|
|
432
|
+
}
|
|
433
|
+
catch (readErr) {
|
|
434
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] readOpenOrders failed: ${readErr?.message || readErr}. ` +
|
|
435
|
+
`Falling back to structural resync.`, 'error');
|
|
436
|
+
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
437
|
+
await bot.manager.requestStructuralGridResync('broadcast uncertain — readOpenOrders failed', { batchId: err?.batchId || null, error: readErr?.message || String(readErr) });
|
|
438
|
+
}
|
|
439
|
+
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
440
|
+
return { executed: false, hadRotation: false, uncertain: true };
|
|
441
|
+
}
|
|
442
|
+
const adopted = [];
|
|
443
|
+
let discarded = [];
|
|
444
|
+
// 2. For each pending broadcast, look for a chain match.
|
|
445
|
+
for (const entry of pending) {
|
|
446
|
+
const match = findChainOrderForSlot(bot, chainSnapshot, entry.slotId, {
|
|
447
|
+
sell: entry.finalInts?.sell,
|
|
448
|
+
receive: entry.finalInts?.receive,
|
|
449
|
+
orderType: entry.orderType,
|
|
450
|
+
fingerprint: entry.fingerprint,
|
|
451
|
+
});
|
|
452
|
+
if (match) {
|
|
453
|
+
adopted.push({ entry, match });
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
discarded.push(entry);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// 2b. Second pass: search for any unmatched chain orders by fingerprint
|
|
460
|
+
// across all known pending slots.
|
|
461
|
+
if (adopted.length < pending.length) {
|
|
462
|
+
const adoptedSlotIds = new Set(adopted.map(a => a.entry.slotId));
|
|
463
|
+
for (const o of chainSnapshot) {
|
|
464
|
+
if (adopted.some(a => a.match.id === o.id))
|
|
465
|
+
continue;
|
|
466
|
+
for (const entry of pending) {
|
|
467
|
+
if (adoptedSlotIds.has(entry.slotId))
|
|
468
|
+
continue;
|
|
469
|
+
const fp = buildChainOrderFingerprint(bot, o, entry.slotId);
|
|
470
|
+
if (fp && bot.manager._pendingBroadcasts?.has(fp)) {
|
|
471
|
+
adopted.push({ entry, match: o });
|
|
472
|
+
adoptedSlotIds.add(entry.slotId);
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Rebuild discarded list to remove newly adopted entries.
|
|
478
|
+
const newlyAdoptedSlotIds = new Set(adopted.map(a => a.entry.slotId));
|
|
479
|
+
discarded = pending.filter(e => !newlyAdoptedSlotIds.has(e.slotId));
|
|
480
|
+
}
|
|
481
|
+
// 3. Apply decisions
|
|
482
|
+
let adoptedCount = 0;
|
|
483
|
+
let discardedCount = 0;
|
|
484
|
+
for (const { entry, match } of adopted) {
|
|
485
|
+
adoptedCount++;
|
|
486
|
+
const plannedOpCtx = opContexts[entry.ctxIndex];
|
|
487
|
+
if (plannedOpCtx && plannedOpCtx.kind === 'create') {
|
|
488
|
+
const chainOrderId = match.id;
|
|
489
|
+
const expectedType = plannedOpCtx.order?.type || entry.orderType;
|
|
490
|
+
try {
|
|
491
|
+
const btsFeeData = getAssetFees('BTS');
|
|
492
|
+
await bot.manager.synchronizeWithChain({
|
|
493
|
+
gridOrderId: plannedOpCtx.order?.id || entry.slotId,
|
|
494
|
+
chainOrderId,
|
|
495
|
+
expectedType,
|
|
496
|
+
fee: btsFeeData.createFee,
|
|
497
|
+
}, 'createOrder');
|
|
498
|
+
}
|
|
499
|
+
catch (syncErr) {
|
|
500
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Failed to adopt matched order ${chainOrderId} for slot ${entry.slotId}: ${syncErr?.message || syncErr}`, 'error');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Remove from pending broadcasts — matched entries are resolved.
|
|
504
|
+
if (entry.fingerprint && bot.manager._pendingBroadcasts?.has(entry.fingerprint)) {
|
|
505
|
+
bot.manager._pendingBroadcasts.delete(entry.fingerprint);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const entry of discarded) {
|
|
509
|
+
discardedCount++;
|
|
510
|
+
const plannedOpCtx = opContexts[entry.ctxIndex];
|
|
511
|
+
if (plannedOpCtx && plannedOpCtx.kind === 'create') {
|
|
512
|
+
try {
|
|
513
|
+
// Restore target grid sizes for discarded CREATEs so the slots are
|
|
514
|
+
// immediately available for the next cycle without waiting for a
|
|
515
|
+
// structural resync. entry.order is the pending-broadcast target
|
|
516
|
+
// order (captured at broadcast time, before the working grid was
|
|
517
|
+
// committed or discarded).
|
|
518
|
+
if (entry.order?.id && entry.order?.size && entry.order?.type) {
|
|
519
|
+
const slot = bot.manager.orders.get(entry.order.id);
|
|
520
|
+
if (slot) {
|
|
521
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Restored target size for discarded CREATE slot ${entry.slotId} (size: ${entry.order.size})`, 'debug');
|
|
522
|
+
const updates = [{
|
|
523
|
+
...slot,
|
|
524
|
+
size: entry.order.size,
|
|
525
|
+
price: entry.order.price,
|
|
526
|
+
}];
|
|
527
|
+
if (typeof bot.manager.applyGridUpdateBatch === 'function') {
|
|
528
|
+
await bot.manager.applyGridUpdateBatch(updates, 'uncertain-broadcast-discard-restore');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (restoreErr) {
|
|
534
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Failed to restore slot ${entry.slotId} after discard: ${restoreErr?.message || restoreErr}`, 'error');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Remove from pending broadcasts.
|
|
538
|
+
if (entry.fingerprint && bot.manager._pendingBroadcasts?.has(entry.fingerprint)) {
|
|
539
|
+
bot.manager._pendingBroadcasts.delete(entry.fingerprint);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// 4. If some entries remain unresolved (broadcasts that point to opContext
|
|
543
|
+
// indices beyond the array — shouldn't happen, but guard defensively),
|
|
544
|
+
// treat them as discarded.
|
|
545
|
+
const remainingAfterDecide = bot.manager._pendingBroadcasts instanceof Map
|
|
546
|
+
? bot.manager._pendingBroadcasts.size
|
|
547
|
+
: 0;
|
|
548
|
+
if (remainingAfterDecide > 0) {
|
|
549
|
+
const remainingEntries = Array.from(bot.manager._pendingBroadcasts.values());
|
|
550
|
+
for (const entry of remainingEntries) {
|
|
551
|
+
discardedCount++;
|
|
552
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Cleaning residual pending broadcast for slot ${entry.slotId} (opIndex=${entry.opIndex})`, 'debug');
|
|
553
|
+
}
|
|
554
|
+
bot.manager._pendingBroadcasts.clear();
|
|
555
|
+
}
|
|
556
|
+
// 5. Log structured summary
|
|
557
|
+
const elapsed = Date.now() - startedAt;
|
|
558
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Reconciled: ${adoptedCount} adopted, ${discardedCount} discarded ` +
|
|
559
|
+
`(opContexts=${opContexts.length}, pending=${pending.length}, ` +
|
|
560
|
+
`chainOrders=${chainSnapshot.length}) in ${elapsed}ms.`, 'info');
|
|
561
|
+
// 6. Persist master grid changes from the reconciliation.
|
|
562
|
+
if (adoptedCount > 0 || discardedCount > 0) {
|
|
563
|
+
if (typeof bot.manager.persistGrid === 'function') {
|
|
564
|
+
try {
|
|
565
|
+
await bot.manager.persistGrid();
|
|
566
|
+
}
|
|
567
|
+
catch (persistErr) {
|
|
568
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] Persist after reconcile failed: ${persistErr?.message || persistErr}`, 'error');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// 7. Request structural resync if any chain orders remain unaccounted for
|
|
573
|
+
// after the reconciliation, ensuring the next cycle re-plans from a clean
|
|
574
|
+
// chain snapshot.
|
|
575
|
+
const alreadyScheduled = bot._structuralGridResyncRunning || bot._structuralGridResyncTimer;
|
|
576
|
+
if (!alreadyScheduled && chainSnapshot.length > 0) {
|
|
577
|
+
const reconciledOrderIds = new Set(adopted.map(a => a.match?.id).filter(Boolean));
|
|
578
|
+
const unreconciledCount = chainSnapshot.filter(o => !reconciledOrderIds.has(o.id)).length;
|
|
579
|
+
if (unreconciledCount > 0) {
|
|
580
|
+
bot.manager.logger.log(`[COW][UNCERTAIN] ${unreconciledCount} chain order(s) remain unreconciled after uncertain broadcast recovery. ` +
|
|
581
|
+
`These may be legitimate pre-existing orders or leftovers from a prior cycle. Requesting structural resync.`, 'warn');
|
|
582
|
+
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
583
|
+
await bot.manager.requestStructuralGridResync('unreconciled orders after uncertain broadcast', {
|
|
584
|
+
batchId: err?.batchId || null,
|
|
585
|
+
pendingCount: pending.length,
|
|
586
|
+
adoptedCount,
|
|
587
|
+
discardedCount,
|
|
588
|
+
unreconciledCount
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
bot._warn?.('[COW][UNCERTAIN] requestStructuralGridResync unavailable; cannot schedule structural resync.');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { executed: false, hadRotation: false, uncertain: true, adoptedCount, discardedCount };
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Auto-cancel one unmatched orphan (price-drift orphan) per cycle.
|
|
600
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
601
|
+
* @returns {Promise<{cancelled: boolean, reason?: string, orderId?: string}>}
|
|
602
|
+
*/
|
|
603
|
+
async function autoCancelOneUnmatchedOrphan(bot) {
|
|
604
|
+
const cycleId = bot._currentCycleId || 0;
|
|
605
|
+
const recoveryActive = bot.manager?._recoveryState?.structuralResyncRequested === true;
|
|
606
|
+
const cycleCap = recoveryActive ? 5 : 1;
|
|
607
|
+
if (bot._autoCancelOrphanCycleMarker === cycleId) {
|
|
608
|
+
if (bot._autoCancelOrphanSubCount >= cycleCap) {
|
|
609
|
+
return { cancelled: false, reason: 'cap-reached-this-cycle', subCount: bot._autoCancelOrphanSubCount };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
bot._autoCancelOrphanCycleMarker = cycleId;
|
|
614
|
+
bot._autoCancelOrphanSubCount = 0;
|
|
615
|
+
}
|
|
616
|
+
const pending = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
|
|
617
|
+
? bot.manager._pendingBroadcasts.size
|
|
618
|
+
: 0;
|
|
619
|
+
if (pending > 0) {
|
|
620
|
+
return { cancelled: false, reason: 'pending-broadcasts-active' };
|
|
621
|
+
}
|
|
622
|
+
const unmatched = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
|
|
623
|
+
? bot.manager._lastUnmatchedChainOrders
|
|
624
|
+
: [];
|
|
625
|
+
if (unmatched.length === 0) {
|
|
626
|
+
return { cancelled: false, reason: 'no-unmatched' };
|
|
627
|
+
}
|
|
628
|
+
const fingerprinted = unmatched.find(u => u && u.fingerprint);
|
|
629
|
+
if (fingerprinted) {
|
|
630
|
+
return { cancelled: false, reason: 'fingerprinted-handle-via-recovery' };
|
|
631
|
+
}
|
|
632
|
+
const target = unmatched.find(u => u && u.reason === 'price-drift-orphan');
|
|
633
|
+
if (!target) {
|
|
634
|
+
return { cancelled: false, reason: 'no-price-drift-orphan', message: 'no price-drift orphan to cancel; other unmatched orders are adoptable' };
|
|
635
|
+
}
|
|
636
|
+
const orderId = target.id || target.orderId || target.chainOrderId;
|
|
637
|
+
if (!orderId) {
|
|
638
|
+
return { cancelled: false, reason: 'no-orderId' };
|
|
639
|
+
}
|
|
640
|
+
if (!chainOrders?.cancelOrder) {
|
|
641
|
+
return { cancelled: false, reason: 'cancelOrder-unavailable' };
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
await chainOrders.cancelOrder(bot.account, bot.privateKey, orderId);
|
|
645
|
+
if (typeof chainOrders.recordOwnCancel === 'function') {
|
|
646
|
+
chainOrders.recordOwnCancel(orderId);
|
|
647
|
+
}
|
|
648
|
+
bot._autoCancelOrphanSubCount++;
|
|
649
|
+
bot.manager.logger.log(`[COW] Auto-cancelled ${bot._autoCancelOrphanSubCount}/${unmatched.length} unmatched chain order ` +
|
|
650
|
+
`(${formatUnmatchedChainOrderForLog(target)}) — per-cycle cap=${cycleCap}.`, 'warn');
|
|
651
|
+
return { cancelled: true, orderId };
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
bot.manager.logger.log(`[COW] Auto-cancel of unmatched chain order ${orderId} failed: ${err?.message || err}`, 'error');
|
|
655
|
+
return { cancelled: false, reason: 'cancel-failed', error: err?.message || String(err) };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Check whether to execute creates in outside-in pair mode.
|
|
660
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
661
|
+
* @param {Array} opContexts
|
|
662
|
+
* @returns {boolean}
|
|
663
|
+
*/
|
|
664
|
+
function shouldExecuteCreatePairMode(bot, opContexts) {
|
|
665
|
+
if (!Array.isArray(opContexts) || opContexts.length < 2)
|
|
666
|
+
return false;
|
|
667
|
+
if (!opContexts.every(ctx => ctx?.kind === 'create' && ctx?.order))
|
|
668
|
+
return false;
|
|
669
|
+
let hasBuy = false;
|
|
670
|
+
let hasSell = false;
|
|
671
|
+
for (const ctx of opContexts) {
|
|
672
|
+
if (ctx.order.type === ORDER_TYPES.BUY)
|
|
673
|
+
hasBuy = true;
|
|
674
|
+
if (ctx.order.type === ORDER_TYPES.SELL)
|
|
675
|
+
hasSell = true;
|
|
676
|
+
if (hasBuy && hasSell)
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Execute operations with retry on BroadcastUncertainError.
|
|
683
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
684
|
+
* @param {Array} operations
|
|
685
|
+
* @param {Array} opContexts
|
|
686
|
+
* @returns {Promise<{result: Object, opContexts: Array}>}
|
|
687
|
+
*/
|
|
688
|
+
async function executeWithRetryOnUncertain(bot, operations, opContexts) {
|
|
689
|
+
const MAX_RETRIES = 1;
|
|
690
|
+
for (let attempt = 1;; attempt++) {
|
|
691
|
+
try {
|
|
692
|
+
return await executeOperationsWithStrategy(bot, operations, opContexts);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
const isRetriable = err instanceof BroadcastUncertainError
|
|
696
|
+
&& !err.partialOnChainState
|
|
697
|
+
&& attempt <= MAX_RETRIES;
|
|
698
|
+
if (isRetriable) {
|
|
699
|
+
bot.manager.logger.log(`[COW] Broadcast uncertain (attempt ${attempt}/${MAX_RETRIES + 1}), retrying...`, 'warn');
|
|
700
|
+
await bot._ensureCredentialDaemonWritable('COW batch retry');
|
|
701
|
+
try {
|
|
702
|
+
const accountRef = bot.accountId || bot.account?.id || bot.account;
|
|
703
|
+
const freshChain = await chainOrders.readOpenOrders(accountRef);
|
|
704
|
+
if (freshChain.length > 0 && bot.manager?.syncFromOpenOrders) {
|
|
705
|
+
await bot.manager.syncFromOpenOrders(freshChain, {
|
|
706
|
+
skipAccounting: true,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch (syncErr) {
|
|
711
|
+
bot.manager.logger.log(`[COW] Pre-retry sync failed (non-fatal): ${syncErr?.message || syncErr}`, 'warn');
|
|
712
|
+
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
throw err;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Execute blockchain operations with appropriate strategy (single batch or pair mode).
|
|
721
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
722
|
+
* @param {Array} operations
|
|
723
|
+
* @param {Array} opContexts
|
|
724
|
+
* @returns {Promise<{result: Object, opContexts: Array}>}
|
|
725
|
+
*/
|
|
726
|
+
async function executeOperationsWithStrategy(bot, operations, opContexts) {
|
|
727
|
+
if (!shouldExecuteCreatePairMode(bot, opContexts)) {
|
|
728
|
+
const result = await chainOrders.executeBatch(bot.account, bot.privateKey, operations);
|
|
729
|
+
return { result, opContexts };
|
|
730
|
+
}
|
|
731
|
+
const createEntries = [];
|
|
732
|
+
for (let i = 0; i < operations.length; i++) {
|
|
733
|
+
createEntries.push({
|
|
734
|
+
operation: operations[i],
|
|
735
|
+
context: opContexts[i],
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
const groups = buildOutsideInPairGroupsForCreateEntries(createEntries);
|
|
739
|
+
const mergedOperationResults = [];
|
|
740
|
+
const mergedRawResults = [];
|
|
741
|
+
const mergedContexts = [];
|
|
742
|
+
for (let idx = 0; idx < groups.length; idx++) {
|
|
743
|
+
const group = groups[idx];
|
|
744
|
+
const groupOps = group.map(e => e.operation);
|
|
745
|
+
const groupContexts = group.map(e => e.context);
|
|
746
|
+
bot.manager.logger.log(`[COW] Broadcasting create pair group ${idx + 1}/${groups.length} (${groupOps.length} op${groupOps.length > 1 ? 's' : ''}, outside->center)`, 'info');
|
|
747
|
+
let groupResult;
|
|
748
|
+
try {
|
|
749
|
+
groupResult = await chainOrders.executeBatch(bot.account, bot.privateKey, groupOps);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
const groupsBroadcast = idx;
|
|
753
|
+
const groupsTotal = groups.length;
|
|
754
|
+
const broadcastedOperationCount = mergedContexts.length;
|
|
755
|
+
bot.manager.logger.log(`[COW] Grouped create execution failed at group ${idx + 1}/${groupsTotal}; ${groupsBroadcast} group(s) already broadcast (${broadcastedOperationCount} op context(s)). Partial on-chain state is possible.`, 'error');
|
|
756
|
+
err.partialOnChainState = groupsBroadcast > 0;
|
|
757
|
+
err.groupsBroadcast = groupsBroadcast;
|
|
758
|
+
err.groupsTotal = groupsTotal;
|
|
759
|
+
err.broadcastedOperationCount = broadcastedOperationCount;
|
|
760
|
+
throw err;
|
|
761
|
+
}
|
|
762
|
+
const groupOpResults = extractOperationResults(groupResult, '', bot.manager?.logger?.log?.bind(bot.manager?.logger));
|
|
763
|
+
mergedOperationResults.push(...groupOpResults);
|
|
764
|
+
mergedRawResults.push(groupResult?.raw || null);
|
|
765
|
+
mergedContexts.push(...groupContexts);
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
result: {
|
|
769
|
+
success: true,
|
|
770
|
+
raw: {
|
|
771
|
+
grouped: true,
|
|
772
|
+
groupsExecuted: groups.length,
|
|
773
|
+
groupResults: mergedRawResults,
|
|
774
|
+
},
|
|
775
|
+
operation_results: mergedOperationResults,
|
|
776
|
+
grouped: true,
|
|
777
|
+
groupsExecuted: groups.length
|
|
778
|
+
},
|
|
779
|
+
opContexts: mergedContexts
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Validate that operations can be executed with available funds before broadcasting.
|
|
784
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
785
|
+
* @param {Array} operations
|
|
786
|
+
* @param {Object} assetA
|
|
787
|
+
* @param {Object} assetB
|
|
788
|
+
* @returns {Object} { isValid: boolean, summary: string }
|
|
789
|
+
*/
|
|
790
|
+
function validateOperationFunds(bot, operations, assetA, assetB) {
|
|
791
|
+
if (!operations || operations.length === 0) {
|
|
792
|
+
return { isValid: true, summary: 'No operations to validate' };
|
|
793
|
+
}
|
|
794
|
+
const { blockchainToFloat, floatToBlockchainInt, quantizeFloat } = require('./order/utils/math');
|
|
795
|
+
let snap = bot.manager?.getChainFundsSnapshot?.();
|
|
796
|
+
if (!snap) {
|
|
797
|
+
snap = { chainFreeSell: 0, chainFreeBuy: 0 };
|
|
798
|
+
bot.manager?.logger?.log?.('[COW][VALIDATION] getChainFundsSnapshot unavailable — fund validation skipped (assuming no balance)', 'warn');
|
|
799
|
+
}
|
|
800
|
+
const netRequiredFunds = { [assetA.id]: 0, [assetB.id]: 0 };
|
|
801
|
+
const runningRequiredFunds = { [assetA.id]: 0, [assetB.id]: 0 };
|
|
802
|
+
const peakRequiredFunds = { [assetA.id]: 0, [assetB.id]: 0 };
|
|
803
|
+
for (const op of operations) {
|
|
804
|
+
if (!op?.op_data)
|
|
805
|
+
continue;
|
|
806
|
+
let sellAssetId = null;
|
|
807
|
+
let sellAmountInt = 0;
|
|
808
|
+
if (op.op_name === 'limit_order_create') {
|
|
809
|
+
sellAssetId = op.op_data.amount_to_sell?.asset_id;
|
|
810
|
+
sellAmountInt = op.op_data.amount_to_sell?.amount;
|
|
811
|
+
}
|
|
812
|
+
else if (op.op_name === 'limit_order_update') {
|
|
813
|
+
sellAssetId = op.op_data.new_price?.base?.asset_id;
|
|
814
|
+
sellAmountInt = op.op_data.new_price?.base?.amount;
|
|
815
|
+
}
|
|
816
|
+
if (sellAssetId && (sellAmountInt !== undefined && sellAmountInt !== null)) {
|
|
817
|
+
const precision = (sellAssetId === assetA.id) ? assetA.precision : assetB.precision;
|
|
818
|
+
const assetSymbol = (sellAssetId === assetA.id) ? assetA.symbol : assetB.symbol;
|
|
819
|
+
if (Number(sellAmountInt) <= 0) {
|
|
820
|
+
return {
|
|
821
|
+
isValid: false,
|
|
822
|
+
summary: `[VALIDATION] CRITICAL: Zero amount order detected for ${assetSymbol} (assetId=${sellAssetId})`,
|
|
823
|
+
violations: [{ asset: assetSymbol, sizeInt: sellAmountInt, reason: 'Zero amount' }]
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
let signedDelta = 0;
|
|
827
|
+
if (op.op_name === 'limit_order_update') {
|
|
828
|
+
const deltaAssetId = op.op_data.delta_amount_to_sell?.asset_id;
|
|
829
|
+
const deltaSellInt = op.op_data.delta_amount_to_sell?.amount;
|
|
830
|
+
if (deltaAssetId === sellAssetId && Number.isFinite(Number(deltaSellInt))) {
|
|
831
|
+
signedDelta = blockchainToFloat(deltaSellInt, precision);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
signedDelta = blockchainToFloat(sellAmountInt, precision);
|
|
836
|
+
}
|
|
837
|
+
netRequiredFunds[sellAssetId] = quantizeFloat((netRequiredFunds[sellAssetId] || 0) + signedDelta, precision);
|
|
838
|
+
runningRequiredFunds[sellAssetId] = quantizeFloat((runningRequiredFunds[sellAssetId] || 0) + signedDelta, precision);
|
|
839
|
+
const nextPeak = Math.max(Number(peakRequiredFunds[sellAssetId] || 0), Number(runningRequiredFunds[sellAssetId] || 0));
|
|
840
|
+
peakRequiredFunds[sellAssetId] = quantizeFloat(nextPeak, precision);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const availableFunds = {
|
|
844
|
+
[assetA.id]: quantizeFloat(snap.chainFreeSell || 0, assetA.precision),
|
|
845
|
+
[assetB.id]: quantizeFloat(snap.chainFreeBuy || 0, assetB.precision)
|
|
846
|
+
};
|
|
847
|
+
const fundViolations = [];
|
|
848
|
+
for (const assetId in peakRequiredFunds) {
|
|
849
|
+
const required = peakRequiredFunds[assetId];
|
|
850
|
+
const netRequired = netRequiredFunds[assetId] || 0;
|
|
851
|
+
const available = availableFunds[assetId] || 0;
|
|
852
|
+
const prec = (assetId === assetA.id) ? assetA.precision : assetB.precision;
|
|
853
|
+
if (floatToBlockchainInt(required, prec) > floatToBlockchainInt(available, prec)) {
|
|
854
|
+
fundViolations.push({
|
|
855
|
+
asset: assetId === assetA.id ? assetA.symbol : assetB.symbol,
|
|
856
|
+
required,
|
|
857
|
+
netRequired,
|
|
858
|
+
available,
|
|
859
|
+
deficit: quantizeFloat(required - available, prec)
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (fundViolations.length > 0) {
|
|
864
|
+
let summary = `[VALIDATION] Fund validation FAILED:\n`;
|
|
865
|
+
for (const v of fundViolations) {
|
|
866
|
+
summary += ` ${v.asset}: peakRequired=${Format.formatAmount8(v.required)}, netRequired=${Format.formatAmount8(v.netRequired)}, available=${Format.formatAmount8(v.available)}, deficit=${Format.formatAmount8(v.deficit)}\n`;
|
|
867
|
+
}
|
|
868
|
+
return { isValid: false, summary: summary.trim(), violations: fundViolations };
|
|
869
|
+
}
|
|
870
|
+
const summary = `[VALIDATION] PASSED: ${operations.length} operations`;
|
|
871
|
+
return { isValid: true, summary };
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Resolve the ideal size from an order-like object with fallback.
|
|
875
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
876
|
+
* @param {Object|null} orderLike
|
|
877
|
+
* @param {number|null} [fallbackSize=null]
|
|
878
|
+
* @returns {number|null}
|
|
879
|
+
*/
|
|
880
|
+
function resolveIdealSizeForValidation(bot, orderLike, fallbackSize = null) {
|
|
881
|
+
const candidates = [
|
|
882
|
+
orderLike?.idealSize,
|
|
883
|
+
orderLike?.order?.idealSize,
|
|
884
|
+
orderLike?.size,
|
|
885
|
+
orderLike?.order?.size,
|
|
886
|
+
fallbackSize
|
|
887
|
+
];
|
|
888
|
+
for (const candidate of candidates) {
|
|
889
|
+
const numeric = Number(candidate);
|
|
890
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
891
|
+
return numeric;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Validate that an order size is safe to execute.
|
|
898
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
899
|
+
* @param {number} size
|
|
900
|
+
* @param {string} type
|
|
901
|
+
* @param {Object|null} [orderLike=null]
|
|
902
|
+
* @param {number|null} [fallbackSize=null]
|
|
903
|
+
* @returns {import('./types').OrderValidationResult}
|
|
904
|
+
*/
|
|
905
|
+
function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
|
|
906
|
+
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);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Build COW actions array from a simple plan object.
|
|
910
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
911
|
+
* @param {Object|Array} plan
|
|
912
|
+
* @returns {Array}
|
|
913
|
+
*/
|
|
914
|
+
function buildActionsFromPlan(bot, plan) {
|
|
915
|
+
const normalizedPlan = Array.isArray(plan)
|
|
916
|
+
? { ordersToPlace: plan }
|
|
917
|
+
: (plan || {});
|
|
918
|
+
const { ordersToPlace = [], ordersToRotate = [], ordersToUpdate = [], ordersToCancel = [] } = normalizedPlan;
|
|
919
|
+
const actions = [];
|
|
920
|
+
for (const o of ordersToCancel) {
|
|
921
|
+
if (o?.orderId) {
|
|
922
|
+
actions.push({ type: COW_ACTIONS.CANCEL, id: o.id, orderId: o.orderId });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const r of ordersToRotate) {
|
|
926
|
+
const oldOrder = r?.oldOrder || r;
|
|
927
|
+
const id = oldOrder?.id || r?.id;
|
|
928
|
+
const orderId = oldOrder?.orderId || r?.orderId;
|
|
929
|
+
const newGridId = r?.newGridId || id;
|
|
930
|
+
const newSize = Number.isFinite(Number(r?.newSize))
|
|
931
|
+
? Number(r.newSize)
|
|
932
|
+
: Number(r?.size || oldOrder?.size || 0);
|
|
933
|
+
const newPrice = Number.isFinite(Number(r?.newPrice))
|
|
934
|
+
? Number(r.newPrice)
|
|
935
|
+
: Number(r?.price || oldOrder?.price);
|
|
936
|
+
const orderType = r?.type || oldOrder?.type;
|
|
937
|
+
if (!id || !orderId || !newGridId || !orderType || !Number.isFinite(newPrice) || !(newSize > 0))
|
|
938
|
+
continue;
|
|
939
|
+
actions.push({
|
|
940
|
+
type: COW_ACTIONS.UPDATE,
|
|
941
|
+
id,
|
|
942
|
+
orderId,
|
|
943
|
+
newGridId,
|
|
944
|
+
newSize,
|
|
945
|
+
newPrice,
|
|
946
|
+
order: {
|
|
947
|
+
id: newGridId,
|
|
948
|
+
type: orderType,
|
|
949
|
+
price: newPrice,
|
|
950
|
+
size: newSize
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
for (const o of ordersToUpdate) {
|
|
955
|
+
const partialOrder = o?.partialOrder || o;
|
|
956
|
+
const id = o?.id || partialOrder?.id;
|
|
957
|
+
const orderId = o?.orderId || partialOrder?.orderId;
|
|
958
|
+
const orderType = o?.type || partialOrder?.type;
|
|
959
|
+
const newSize = Number.isFinite(Number(o?.newSize))
|
|
960
|
+
? Number(o.newSize)
|
|
961
|
+
: Number(partialOrder?.size || 0);
|
|
962
|
+
if (!id || !orderId)
|
|
963
|
+
continue;
|
|
964
|
+
actions.push({
|
|
965
|
+
type: COW_ACTIONS.UPDATE,
|
|
966
|
+
id,
|
|
967
|
+
orderId,
|
|
968
|
+
newSize,
|
|
969
|
+
order: {
|
|
970
|
+
...(partialOrder || {}),
|
|
971
|
+
id,
|
|
972
|
+
orderId,
|
|
973
|
+
type: orderType,
|
|
974
|
+
size: newSize
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
for (const o of ordersToPlace) {
|
|
979
|
+
if (!o?.id)
|
|
980
|
+
continue;
|
|
981
|
+
actions.push({ type: COW_ACTIONS.CREATE, id: o.id, order: o });
|
|
982
|
+
}
|
|
983
|
+
return actions;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Build a COW result object (workingGrid + actions) from a simple plan.
|
|
987
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
988
|
+
* @param {Object|Array} plan
|
|
989
|
+
* @returns {{workingGrid: import('./types').WorkingGrid, workingIndexes: Object, workingBoundary: number, actions: Array}}
|
|
990
|
+
*/
|
|
991
|
+
function buildCowResultFromPlan(bot, plan) {
|
|
992
|
+
const workingGrid = new WorkingGrid(bot.manager.orders, {
|
|
993
|
+
baseVersion: Number.isFinite(Number(bot.manager._gridVersion)) ? bot.manager._gridVersion : 0
|
|
994
|
+
});
|
|
995
|
+
const workingBoundary = bot.manager.boundaryIdx;
|
|
996
|
+
const actions = buildActionsFromPlan(bot, plan);
|
|
997
|
+
for (const action of actions) {
|
|
998
|
+
if (action.type === COW_ACTIONS.CANCEL) {
|
|
999
|
+
const current = workingGrid.get(action.id);
|
|
1000
|
+
if (!current)
|
|
1001
|
+
continue;
|
|
1002
|
+
workingGrid.set(action.id, convertToSpreadPlaceholder(current));
|
|
1003
|
+
}
|
|
1004
|
+
else if (action.type === COW_ACTIONS.CREATE) {
|
|
1005
|
+
if (!action.id || !action.order)
|
|
1006
|
+
continue;
|
|
1007
|
+
const current = workingGrid.get(action.id) || { id: action.id };
|
|
1008
|
+
workingGrid.set(action.id, {
|
|
1009
|
+
...current,
|
|
1010
|
+
...action.order,
|
|
1011
|
+
id: action.id,
|
|
1012
|
+
state: ORDER_STATES.VIRTUAL,
|
|
1013
|
+
orderId: null
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
else if (action.type === COW_ACTIONS.UPDATE) {
|
|
1017
|
+
if (action.newGridId && action.newGridId !== action.id) {
|
|
1018
|
+
const current = workingGrid.get(action.id);
|
|
1019
|
+
if (current) {
|
|
1020
|
+
workingGrid.set(action.id, convertToSpreadPlaceholder(current));
|
|
1021
|
+
}
|
|
1022
|
+
const targetId = action.newGridId;
|
|
1023
|
+
const targetCurrent = workingGrid.get(targetId) || { id: targetId };
|
|
1024
|
+
const rotatedSize = Number.isFinite(Number(action.newSize))
|
|
1025
|
+
? Number(action.newSize)
|
|
1026
|
+
: Number(targetCurrent.size || 0);
|
|
1027
|
+
const rotatedPrice = Number.isFinite(Number(action.newPrice))
|
|
1028
|
+
? Number(action.newPrice)
|
|
1029
|
+
: Number(action.order?.price ?? targetCurrent.price);
|
|
1030
|
+
workingGrid.set(targetId, {
|
|
1031
|
+
...targetCurrent,
|
|
1032
|
+
...(action.order || {}),
|
|
1033
|
+
id: targetId,
|
|
1034
|
+
size: rotatedSize,
|
|
1035
|
+
price: rotatedPrice,
|
|
1036
|
+
state: ORDER_STATES.VIRTUAL,
|
|
1037
|
+
orderId: null
|
|
1038
|
+
});
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const current = workingGrid.get(action.id);
|
|
1042
|
+
if (!current)
|
|
1043
|
+
continue;
|
|
1044
|
+
const newSize = Number.isFinite(Number(action.newSize))
|
|
1045
|
+
? Number(action.newSize)
|
|
1046
|
+
: Number(current.size || 0);
|
|
1047
|
+
workingGrid.set(action.id, {
|
|
1048
|
+
...current,
|
|
1049
|
+
...(action.order || {}),
|
|
1050
|
+
id: action.id,
|
|
1051
|
+
orderId: action.orderId || current.orderId,
|
|
1052
|
+
size: newSize
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
workingGrid,
|
|
1058
|
+
workingIndexes: workingGrid.getIndexes(),
|
|
1059
|
+
workingBoundary,
|
|
1060
|
+
actions
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Restore skipped update slots in the working grid to master state.
|
|
1065
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
1066
|
+
* @param {import('./types').WorkingGrid} workingGrid
|
|
1067
|
+
* @param {Set<string>} skippedSlotIds
|
|
1068
|
+
* @param {number} [skippedCount=0]
|
|
1069
|
+
*/
|
|
1070
|
+
function restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedSlotIds, skippedCount = 0) {
|
|
1071
|
+
if (!workingGrid || !skippedSlotIds || skippedSlotIds.size === 0) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const masterVersion = Number.isFinite(Number(bot.manager?._gridVersion))
|
|
1075
|
+
? Number(bot.manager._gridVersion)
|
|
1076
|
+
: undefined;
|
|
1077
|
+
for (const slotId of skippedSlotIds) {
|
|
1078
|
+
workingGrid.syncFromMaster(bot.manager.orders, slotId, masterVersion);
|
|
1079
|
+
}
|
|
1080
|
+
bot.manager.logger.log(`[COW] Restored ${skippedSlotIds.size} slot(s) after ${skippedCount} skipped update action(s).`, 'debug');
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* COW broadcast: Execute blockchain operations and commit working grid on success.
|
|
1084
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
1085
|
+
* @param {Object} cowResult
|
|
1086
|
+
* @returns {Promise<Object>}
|
|
1087
|
+
*/
|
|
1088
|
+
async function updateOrdersOnChainBatchCOW(bot, cowResult) {
|
|
1089
|
+
bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
|
|
1090
|
+
const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
|
|
1091
|
+
if (bot.config.dryRun) {
|
|
1092
|
+
const cancelCount = actions.filter(a => a.type === COW_ACTIONS.CANCEL).length;
|
|
1093
|
+
const createCount = actions.filter(a => a.type === COW_ACTIONS.CREATE).length;
|
|
1094
|
+
const updateCount = actions.filter(a => a.type === COW_ACTIONS.UPDATE).length;
|
|
1095
|
+
if (cancelCount > 0)
|
|
1096
|
+
bot.manager.logger.log(`Dry run: would cancel ${cancelCount} orders`, 'info');
|
|
1097
|
+
if (createCount > 0)
|
|
1098
|
+
bot.manager.logger.log(`Dry run: would place ${createCount} new orders`, 'info');
|
|
1099
|
+
if (updateCount > 0)
|
|
1100
|
+
bot.manager.logger.log(`Dry run: would update ${updateCount} orders`, 'info');
|
|
1101
|
+
return { executed: true, hadRotation: false };
|
|
1102
|
+
}
|
|
1103
|
+
const createSlotValidation = validateCreateTargetSlots(actions, bot.manager?.orders);
|
|
1104
|
+
if (!createSlotValidation.isValid) {
|
|
1105
|
+
for (const violation of createSlotValidation.violations) {
|
|
1106
|
+
bot.manager.logger.log(`[COW] Rejecting CREATE for occupied slot ${violation.targetId}: ` +
|
|
1107
|
+
`existing orderId=${violation.currentOrderId}, type=${violation.currentType}, state=${violation.currentState}`, 'error');
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
executed: false,
|
|
1111
|
+
aborted: true,
|
|
1112
|
+
reason: 'CREATE_SLOT_OCCUPIED',
|
|
1113
|
+
violations: createSlotValidation.violations,
|
|
1114
|
+
hadRotation: false
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const hasCreateActions = actions.some(action => action.type === COW_ACTIONS.CREATE);
|
|
1118
|
+
if (hasCreateActions && bot.manager?._recoveryExhaustedAt) {
|
|
1119
|
+
const exhaustedAge = Date.now() - bot.manager._recoveryExhaustedAt;
|
|
1120
|
+
bot.manager.logger.log?.(`[RECOVERY-EXHAUSTED] Blocking ${actions.filter(a => a.type === COW_ACTIONS.CREATE).length} CREATE(s) ` +
|
|
1121
|
+
`(exhausted ${(exhaustedAge / 1000).toFixed(0)}s ago). ` +
|
|
1122
|
+
`Waiting for next fill or sync cycle to reset recovery state.`, 'warn');
|
|
1123
|
+
return {
|
|
1124
|
+
executed: false,
|
|
1125
|
+
aborted: true,
|
|
1126
|
+
reason: 'RECOVERY_EXHAUSTED',
|
|
1127
|
+
hadRotation: false
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
const unmatchedChainOrders = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
|
|
1131
|
+
? bot.manager._lastUnmatchedChainOrders
|
|
1132
|
+
: [];
|
|
1133
|
+
const pendingBroadcasts = (bot.manager && bot.manager._pendingBroadcasts instanceof Map)
|
|
1134
|
+
? Array.from(bot.manager._pendingBroadcasts.values())
|
|
1135
|
+
: [];
|
|
1136
|
+
if (hasCreateActions && (unmatchedChainOrders.length > 0 || pendingBroadcasts.length > 0)) {
|
|
1137
|
+
if (pendingBroadcasts.length > 0) {
|
|
1138
|
+
bot.manager.logger.log(`[COW] Rejecting CREATE batch: ${pendingBroadcasts.length} pending broadcast(s) from a prior uncertain ` +
|
|
1139
|
+
`broadcast. Running recovery before placing replacement orders.`, 'error');
|
|
1140
|
+
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
1141
|
+
if (bot.manager._recoveryState)
|
|
1142
|
+
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
1143
|
+
await bot.manager.requestStructuralGridResync('pending broadcasts before COW create', { pendingBroadcasts: pendingBroadcasts.map(p => p.slotId) });
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
await reconcileAfterUncertainBroadcast(bot, new BroadcastUncertainError('rejected CREATE batch had pending broadcasts', {
|
|
1147
|
+
operations: pendingBroadcasts.map(p => p.order),
|
|
1148
|
+
accountName: bot.account,
|
|
1149
|
+
batchId: bot._currentBatchId || null,
|
|
1150
|
+
payload: null,
|
|
1151
|
+
timeoutMs: null
|
|
1152
|
+
}), []);
|
|
1153
|
+
}
|
|
1154
|
+
catch (recoverErr) {
|
|
1155
|
+
bot.manager.logger.log(`[COW] Recovery from pending broadcasts failed: ${recoverErr?.message || recoverErr}`, 'error');
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
executed: false,
|
|
1159
|
+
aborted: true,
|
|
1160
|
+
reason: 'PENDING_BROADCASTS',
|
|
1161
|
+
hadRotation: false
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
const unmatchedSample = unmatchedChainOrders
|
|
1165
|
+
.slice(0, 3)
|
|
1166
|
+
.map(o => formatUnmatchedChainOrderForLog(o))
|
|
1167
|
+
.join(' | ');
|
|
1168
|
+
bot.manager.logger.log(`[COW] ${unmatchedChainOrders.length} unmatched chain order(s) blocking CREATES ` +
|
|
1169
|
+
(unmatchedSample ? `(${unmatchedSample})` : '') +
|
|
1170
|
+
` — adopting via sync instead of cancelling`, 'info');
|
|
1171
|
+
try {
|
|
1172
|
+
const accountRef = bot.account;
|
|
1173
|
+
const freshSnapshot = await chainOrders.readOpenOrders(accountRef);
|
|
1174
|
+
if (freshSnapshot && freshSnapshot.length > 0) {
|
|
1175
|
+
const syncResult = await bot.manager.syncFromOpenOrders(freshSnapshot, {
|
|
1176
|
+
skipAccounting: true,
|
|
1177
|
+
});
|
|
1178
|
+
if (syncResult && Array.isArray(syncResult.unmatchedChainOrders)) {
|
|
1179
|
+
const processed = (syncResult.filledOrders?.length || 0) +
|
|
1180
|
+
(syncResult.updatedOrders?.length || 0) +
|
|
1181
|
+
(syncResult.ordersNeedingCorrection?.length || 0);
|
|
1182
|
+
if (processed > 0) {
|
|
1183
|
+
bot.manager._lastUnmatchedChainOrders = syncResult.unmatchedChainOrders;
|
|
1184
|
+
bot.manager.logger.log(`[COW] Adopted chain order(s) via sync: ${processed} processed, ` +
|
|
1185
|
+
`${syncResult.unmatchedChainOrders.length} still unmatched`, 'info');
|
|
1186
|
+
}
|
|
1187
|
+
else {
|
|
1188
|
+
const syncUnmatchedCount = syncResult.unmatchedChainOrders.length;
|
|
1189
|
+
bot.manager.logger.log(`[COW] Sync returned without processing (processed=0, ` +
|
|
1190
|
+
`unmatched=${syncUnmatchedCount} in result, ` +
|
|
1191
|
+
`_lastUnmatchedChainOrders=${unmatchedChainOrders.length}). ` +
|
|
1192
|
+
`Structural resync will handle adoption.`, syncUnmatchedCount > 0 ? 'warn' : 'debug');
|
|
1193
|
+
if (syncUnmatchedCount > 0 && syncUnmatchedCount !== unmatchedChainOrders.length) {
|
|
1194
|
+
bot.manager._lastUnmatchedChainOrders = syncResult.unmatchedChainOrders.map((o) => ({ ...o }));
|
|
1195
|
+
bot.manager.logger.log(`[COW] Updated _lastUnmatchedChainOrders from sync result: ` +
|
|
1196
|
+
`${unmatchedChainOrders.length} → ${syncUnmatchedCount}`, 'debug');
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
catch (syncErr) {
|
|
1203
|
+
bot.manager.logger.log(`[COW] Failed to sync/unmatched orders: ${syncErr?.message || syncErr}`, 'warn');
|
|
1204
|
+
}
|
|
1205
|
+
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
1206
|
+
if (bot.manager._recoveryState)
|
|
1207
|
+
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
1208
|
+
await bot.manager.requestStructuralGridResync('unmatched chain orders before COW create', { unmatchedChainOrders: unmatchedChainOrders });
|
|
1209
|
+
}
|
|
1210
|
+
bot.manager.logger.log(`[COW] Rejecting CREATE batch after sync: working grid invalidated by master mutation`, 'info');
|
|
1211
|
+
return {
|
|
1212
|
+
executed: false,
|
|
1213
|
+
aborted: true,
|
|
1214
|
+
reason: 'UNMATCHED_CHAIN_ORDERS',
|
|
1215
|
+
hadRotation: false
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const { assetA, assetB } = bot.manager.assets;
|
|
1219
|
+
const operations = [];
|
|
1220
|
+
const opContexts = [];
|
|
1221
|
+
const skippedUpdateSlotIds = new Set();
|
|
1222
|
+
let skippedUpdateCount = 0;
|
|
1223
|
+
const idsToLock = new Set();
|
|
1224
|
+
for (const action of actions) {
|
|
1225
|
+
if (action.type === COW_ACTIONS.CANCEL && action.orderId) {
|
|
1226
|
+
idsToLock.add(action.orderId);
|
|
1227
|
+
if (action.id)
|
|
1228
|
+
idsToLock.add(action.id);
|
|
1229
|
+
}
|
|
1230
|
+
else if (action.type === COW_ACTIONS.CREATE && action.id) {
|
|
1231
|
+
idsToLock.add(action.id);
|
|
1232
|
+
}
|
|
1233
|
+
else if (action.type === COW_ACTIONS.UPDATE && action.orderId) {
|
|
1234
|
+
idsToLock.add(action.orderId);
|
|
1235
|
+
if (action.id)
|
|
1236
|
+
idsToLock.add(action.id);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
bot.manager.lockOrders(idsToLock);
|
|
1240
|
+
try {
|
|
1241
|
+
bot._batchInFlight = true;
|
|
1242
|
+
bot._markGridActivity('batch start');
|
|
1243
|
+
bot.manager._setRebalanceState(REBALANCE_STATES.BROADCASTING);
|
|
1244
|
+
bot.manager.startBroadcasting();
|
|
1245
|
+
for (const action of actions) {
|
|
1246
|
+
if (action.type === COW_ACTIONS.CANCEL) {
|
|
1247
|
+
try {
|
|
1248
|
+
const op = await chainOrders.buildCancelOrderOp(bot.account, action.orderId);
|
|
1249
|
+
operations.push(op);
|
|
1250
|
+
const order = bot.manager.orders.get(action.id) || { id: action.id, orderId: action.orderId };
|
|
1251
|
+
opContexts.push({ kind: 'cancel', order });
|
|
1252
|
+
}
|
|
1253
|
+
catch (err) {
|
|
1254
|
+
const orderNotFound = /\bnot found\b/i.test(err.message) || /\bdoes not exist\b/i.test(err.message);
|
|
1255
|
+
if (orderNotFound) {
|
|
1256
|
+
bot.manager.logger.log(`[COW] Cancel skipped for ${action.id} (${action.orderId}): order already removed from chain`, 'debug');
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
bot.manager.logger.log(`Failed to prepare cancel op for ${action.id}: ${err.message}`, 'error');
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
else if (action.type === COW_ACTIONS.CREATE) {
|
|
1264
|
+
try {
|
|
1265
|
+
const order = action.order;
|
|
1266
|
+
const sizeValidation = validateOrderSizeForExecution(bot, order.size, order.type, order, order.size);
|
|
1267
|
+
if (!sizeValidation.isValid) {
|
|
1268
|
+
bot.manager.logger.log(`Skipping create op for ${action.id}: ${sizeValidation.reason}`, 'warn');
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
const liveSlot = bot.manager.orders.get(order.id);
|
|
1272
|
+
const plannedPrice = Number(order.price);
|
|
1273
|
+
const livePrice = liveSlot ? Number(liveSlot.price) : NaN;
|
|
1274
|
+
const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
|
|
1275
|
+
? Math.abs(livePrice - plannedPrice)
|
|
1276
|
+
: 0;
|
|
1277
|
+
const effectiveOrder = (priceDrift > 0)
|
|
1278
|
+
? { ...order, price: livePrice, size: order.size, type: order.type }
|
|
1279
|
+
: order;
|
|
1280
|
+
if (priceDrift > 0) {
|
|
1281
|
+
bot.manager.logger.log(`[COW] Pre-broadcast price freshness: slot ${order.id} ` +
|
|
1282
|
+
`drifted from planned=${plannedPrice} to live=${livePrice} ` +
|
|
1283
|
+
`(diff=${priceDrift}); rebuilding CREATE op with live price.`, 'debug');
|
|
1284
|
+
}
|
|
1285
|
+
const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
|
|
1286
|
+
const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
|
|
1287
|
+
if (!buildResult) {
|
|
1288
|
+
bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
operations.push(buildResult.op);
|
|
1292
|
+
opContexts.push({ kind: 'create', id: order.id, order: effectiveOrder, args, finalInts: buildResult.finalInts });
|
|
1293
|
+
recordPendingBroadcast(bot, {
|
|
1294
|
+
opIndex: operations.length - 1,
|
|
1295
|
+
ctxIndex: opContexts.length - 1,
|
|
1296
|
+
order: effectiveOrder,
|
|
1297
|
+
finalInts: buildResult.finalInts
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
bot.manager.logger.log(`Failed to prepare create op for ${action.id}: ${err.message}`, 'error');
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
else if (action.type === COW_ACTIONS.UPDATE) {
|
|
1305
|
+
try {
|
|
1306
|
+
if (action.newGridId && action.newGridId !== action.id) {
|
|
1307
|
+
const masterOrder = bot.manager.orders.get(action.id);
|
|
1308
|
+
const orderType = action.order?.type || masterOrder?.type;
|
|
1309
|
+
const newPrice = Number.isFinite(Number(action.newPrice))
|
|
1310
|
+
? Number(action.newPrice)
|
|
1311
|
+
: Number(action.order?.price);
|
|
1312
|
+
const newSize = Number.isFinite(Number(action.newSize))
|
|
1313
|
+
? Number(action.newSize)
|
|
1314
|
+
: Number(action.order?.size || 0);
|
|
1315
|
+
if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const rotationSizeValidation = validateOrderSizeForExecution(bot, newSize, orderType, action.order, newSize);
|
|
1319
|
+
if (!rotationSizeValidation.isValid) {
|
|
1320
|
+
bot.manager.logger.log(`Skipping rotation update ${action.id} -> ${action.newGridId}: ${rotationSizeValidation.reason}`, 'warn');
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
|
|
1324
|
+
const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
|
|
1325
|
+
if (!buildResult) {
|
|
1326
|
+
skippedUpdateCount++;
|
|
1327
|
+
if (action.id)
|
|
1328
|
+
skippedUpdateSlotIds.add(action.id);
|
|
1329
|
+
if (action.newGridId)
|
|
1330
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
1331
|
+
bot.manager.logger.log(`[COW] Skipping rotation update ${action.id} -> ${action.newGridId}: no blockchain delta`, 'debug');
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
operations.push(buildResult.op);
|
|
1335
|
+
opContexts.push({
|
|
1336
|
+
kind: 'rotation',
|
|
1337
|
+
rotation: {
|
|
1338
|
+
oldOrder: { ...masterOrder },
|
|
1339
|
+
newGridId: action.newGridId,
|
|
1340
|
+
newPrice,
|
|
1341
|
+
newSize,
|
|
1342
|
+
type: orderType
|
|
1343
|
+
},
|
|
1344
|
+
finalInts: buildResult.finalInts
|
|
1345
|
+
});
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const newSize = Number.isFinite(Number(action.newSize))
|
|
1349
|
+
? Number(action.newSize)
|
|
1350
|
+
: Number(action.order?.size || 0);
|
|
1351
|
+
const masterOrder = bot.manager.orders.get(action.id);
|
|
1352
|
+
const orderType = action.order?.type || masterOrder?.type;
|
|
1353
|
+
const cachedRawOnChain = masterOrder?.rawOnChain || action.order?.rawOnChain || null;
|
|
1354
|
+
const op = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell: newSize, orderType }, cachedRawOnChain);
|
|
1355
|
+
if (!op) {
|
|
1356
|
+
skippedUpdateCount++;
|
|
1357
|
+
if (action.id)
|
|
1358
|
+
skippedUpdateSlotIds.add(action.id);
|
|
1359
|
+
if (action.newGridId)
|
|
1360
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
1361
|
+
bot.manager.logger.log(`[COW] Skipping size update ${action.id} (${action.orderId}): no blockchain delta`, 'debug');
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
operations.push(op.op);
|
|
1365
|
+
const partialOrder = masterOrder || {
|
|
1366
|
+
id: action.id,
|
|
1367
|
+
orderId: action.orderId,
|
|
1368
|
+
type: orderType
|
|
1369
|
+
};
|
|
1370
|
+
opContexts.push({ kind: 'size-update', updateInfo: { partialOrder, newSize }, finalInts: op.finalInts });
|
|
1371
|
+
}
|
|
1372
|
+
catch (err) {
|
|
1373
|
+
const orderNotFound = /\bnot found\b/i.test(err.message) || /\bdoes not exist\b/i.test(err.message);
|
|
1374
|
+
if (orderNotFound) {
|
|
1375
|
+
try {
|
|
1376
|
+
const fbOrder = action.order || bot.manager.orders.get(action.id);
|
|
1377
|
+
const fbType = fbOrder?.type;
|
|
1378
|
+
const fbSize = action.newSize || fbOrder?.size || 0;
|
|
1379
|
+
const targetSlotId = action.newGridId || action.id;
|
|
1380
|
+
const plannedPrice = action.newPrice || action.order?.price || 0;
|
|
1381
|
+
const liveSlotForPrice = bot.manager.orders.get(targetSlotId);
|
|
1382
|
+
const livePrice = liveSlotForPrice ? Number(liveSlotForPrice.price) : NaN;
|
|
1383
|
+
const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
|
|
1384
|
+
? Math.abs(livePrice - plannedPrice)
|
|
1385
|
+
: 0;
|
|
1386
|
+
const fbPrice = (priceDrift > 0) ? livePrice : plannedPrice;
|
|
1387
|
+
if (priceDrift > 0) {
|
|
1388
|
+
bot.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
|
|
1389
|
+
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})`, 'debug');
|
|
1390
|
+
}
|
|
1391
|
+
const sizeCheck = validateOrderSizeForExecution(bot, fbSize, fbType, fbOrder, fbSize);
|
|
1392
|
+
if (!sizeCheck.isValid) {
|
|
1393
|
+
bot.manager.logger.log(`[COW] CREATE fallback for ${action.id} rejected by size validation: ${sizeCheck.reason}`, 'warn');
|
|
1394
|
+
}
|
|
1395
|
+
else if (fbType && fbSize > 0 && fbPrice > 0) {
|
|
1396
|
+
const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
|
|
1397
|
+
const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
|
|
1398
|
+
if (fbResult) {
|
|
1399
|
+
operations.push(fbResult.op);
|
|
1400
|
+
opContexts.push({
|
|
1401
|
+
kind: 'create',
|
|
1402
|
+
id: targetSlotId,
|
|
1403
|
+
order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
|
|
1404
|
+
args: { amountToSell: fbArgs.amountToSell, minToReceive: fbArgs.minToReceive },
|
|
1405
|
+
finalInts: fbResult.finalInts
|
|
1406
|
+
});
|
|
1407
|
+
recordPendingBroadcast(bot, {
|
|
1408
|
+
opIndex: operations.length - 1,
|
|
1409
|
+
ctxIndex: opContexts.length - 1,
|
|
1410
|
+
order: { id: targetSlotId, type: fbType, price: fbPrice, size: fbSize },
|
|
1411
|
+
finalInts: fbResult.finalInts
|
|
1412
|
+
});
|
|
1413
|
+
bot.manager.logger.log(`[COW] Recovered "not found" for ${action.id}: converted UPDATE to CREATE for slot ${targetSlotId}`, 'warn');
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
catch (fbErr) {
|
|
1419
|
+
bot.manager.logger.log(`[COW] CREATE fallback also failed for ${action.id}: ${fbErr.message}`, 'warn');
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
bot.manager.logger.log(`Failed to prepare update op for ${action.id}: ${err.message}`, 'error');
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
if (skippedUpdateCount > 0) {
|
|
1427
|
+
restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
|
|
1428
|
+
}
|
|
1429
|
+
if (operations.length === 0) {
|
|
1430
|
+
bot.manager._setRebalanceState(REBALANCE_STATES.NORMAL);
|
|
1431
|
+
return { executed: false, hadRotation: false };
|
|
1432
|
+
}
|
|
1433
|
+
const validation = validateOperationFunds(bot, operations, assetA, assetB);
|
|
1434
|
+
bot.manager.logger.log(validation.summary, validation.isValid ? 'info' : 'warn');
|
|
1435
|
+
if (!validation.isValid) {
|
|
1436
|
+
bot.manager.logger.log(`Skipping batch broadcast: ${validation.violations.length} fund violation(s) detected`, 'warn');
|
|
1437
|
+
bot.manager._setRebalanceState(REBALANCE_STATES.NORMAL);
|
|
1438
|
+
return { executed: false, hadRotation: false };
|
|
1439
|
+
}
|
|
1440
|
+
await bot._ensureCredentialDaemonWritable('COW batch broadcast');
|
|
1441
|
+
bot.manager.logger.log(`[COW] Broadcasting batch with ${operations.length} operations...`, 'info');
|
|
1442
|
+
bot._lastBroadcastHeartbeatAt = Date.now();
|
|
1443
|
+
const execution = await executeWithRetryOnUncertain(bot, operations, opContexts);
|
|
1444
|
+
const result = execution.result;
|
|
1445
|
+
const executedContexts = execution.opContexts;
|
|
1446
|
+
bot.manager.pauseFundRecalc();
|
|
1447
|
+
try {
|
|
1448
|
+
bot.manager._throwOnIllegalState = true;
|
|
1449
|
+
if (result.success) {
|
|
1450
|
+
const preCommitResults = extractOperationResults(result, 'pre-commit-integrity', bot.manager?.logger?.log?.bind(bot.manager?.logger));
|
|
1451
|
+
const missingCreateResults = findMissingCreateResultContexts(preCommitResults, executedContexts);
|
|
1452
|
+
if (missingCreateResults.length > 0) {
|
|
1453
|
+
const missingSlots = missingCreateResults
|
|
1454
|
+
.map(item => item.ctx?.order?.id || item.ctx?.id || `op-${item.index}`)
|
|
1455
|
+
.join(', ');
|
|
1456
|
+
bot.manager.logger.log(`[COW] Refusing to commit working grid: ${missingCreateResults.length} CREATE op(s) ` +
|
|
1457
|
+
`returned no chainOrderId (${missingSlots}). Discarding working grid and syncing from chain.`, 'error');
|
|
1458
|
+
bot.manager._clearWorkingGridRef();
|
|
1459
|
+
bot.manager._setRebalanceState(REBALANCE_STATES.NORMAL);
|
|
1460
|
+
markMissingCreateResultsAsStructuralBlocker(bot, missingCreateResults);
|
|
1461
|
+
await recoverAfterMissingCreateResults(bot, 'missing create operation results');
|
|
1462
|
+
return {
|
|
1463
|
+
executed: false,
|
|
1464
|
+
hadRotation: false,
|
|
1465
|
+
missingCreateResults: missingCreateResults.map(item => ({
|
|
1466
|
+
index: item.index,
|
|
1467
|
+
slotId: item.ctx?.order?.id || item.ctx?.id || null
|
|
1468
|
+
}))
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
bot.manager.logger.log('[COW] Blockchain success - committing working grid to master', 'info');
|
|
1472
|
+
await bot.manager._commitWorkingGrid(workingGrid, workingIndexes, workingBoundary, { skipRecalc: true });
|
|
1473
|
+
const batchResult = await processBatchResults(bot, result, executedContexts);
|
|
1474
|
+
const persistResult = await bot.manager.persistGrid();
|
|
1475
|
+
if (persistResult && (persistResult.skipped || persistResult.isValid === false)) {
|
|
1476
|
+
bot.manager.logger.log(`[COW][PERSIST-GUARD] First persist attempt was ` +
|
|
1477
|
+
`${persistResult.skipped ? 'skipped' : 'invalid'} ` +
|
|
1478
|
+
`(${persistResult.reason || 'no reason'}); retrying once before ` +
|
|
1479
|
+
`clearing working grid reference.`, 'warn');
|
|
1480
|
+
bot.manager._persistenceWarning = persistResult;
|
|
1481
|
+
const retryResult = await bot.manager.persistGrid();
|
|
1482
|
+
if (retryResult && (retryResult.skipped || retryResult.isValid === false)) {
|
|
1483
|
+
bot.manager.logger.log(`[COW][PERSIST-GUARD] Retry also skipped/invalid ` +
|
|
1484
|
+
`(${retryResult.reason || 'no reason'}). Master grid in memory ` +
|
|
1485
|
+
`is ahead of disk snapshot; structural resync requested.`, 'error');
|
|
1486
|
+
if (typeof bot.manager.requestStructuralGridResync === 'function') {
|
|
1487
|
+
bot.manager._recoveryState = { ...bot.manager._recoveryState, structuralResyncRequested: true };
|
|
1488
|
+
await bot.manager.requestStructuralGridResync('persistence guard triggered after COW batch', { persistReason: retryResult.reason || 'unknown' });
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
delete bot.manager._persistenceWarning;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
else if (bot.manager._persistenceWarning) {
|
|
1496
|
+
delete bot.manager._persistenceWarning;
|
|
1497
|
+
}
|
|
1498
|
+
bot._metrics.batchesExecuted++;
|
|
1499
|
+
bot.manager._clearWorkingGridRef();
|
|
1500
|
+
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
1501
|
+
return { executed: true, hadRotation: true, ...batchResult };
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
bot.manager.logger.log('[COW] Blockchain failed - working grid discarded, master unchanged', 'warn');
|
|
1505
|
+
bot.manager._clearWorkingGridRef();
|
|
1506
|
+
clearPendingBroadcasts(bot.manager?._pendingBroadcasts);
|
|
1507
|
+
return { executed: false, hadRotation: false, ...result };
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
finally {
|
|
1511
|
+
bot.manager._throwOnIllegalState = false;
|
|
1512
|
+
await bot.manager.resumeFundRecalc();
|
|
1513
|
+
bot.manager.stopBroadcasting();
|
|
1514
|
+
const createCount = actions.filter(a => a.type === COW_ACTIONS.CREATE).length;
|
|
1515
|
+
const cancelCount = actions.filter(a => a.type === COW_ACTIONS.CANCEL).length;
|
|
1516
|
+
bot.manager.logger.logFundsStatus(bot.manager, `AFTER COW batch (created=${createCount}, cancelled=${cancelCount})`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
catch (err) {
|
|
1520
|
+
bot.manager.logger.log(`[COW] Batch transaction failed: ${err.message}`, 'error');
|
|
1521
|
+
if (err?.partialOnChainState) {
|
|
1522
|
+
bot.manager.logger.log(`[COW] Non-atomic grouped execution detected (${err.groupsBroadcast}/${err.groupsTotal} groups broadcast). Local rollback cannot undo confirmed on-chain operations; next sync/reconcile will converge state.`, 'warn');
|
|
1523
|
+
}
|
|
1524
|
+
bot.manager.stopBroadcasting();
|
|
1525
|
+
bot.manager._clearWorkingGridRef();
|
|
1526
|
+
if (err instanceof BroadcastUncertainError) {
|
|
1527
|
+
return await reconcileAfterUncertainBroadcast(bot, err, opContexts);
|
|
1528
|
+
}
|
|
1529
|
+
const hardAbortResult = await bot._handleBatchHardAbort(err, 'COW batch processing', operations.length);
|
|
1530
|
+
if (hardAbortResult)
|
|
1531
|
+
return hardAbortResult;
|
|
1532
|
+
const staleOrderIds = new Set();
|
|
1533
|
+
const patterns = [
|
|
1534
|
+
/Limit order (1\.7\.\d+) does not exist/g,
|
|
1535
|
+
/Unable to find Object (1\.7\.\d+)/g,
|
|
1536
|
+
/object (1\.7\.\d+) (?:does not exist|not found)/gi
|
|
1537
|
+
];
|
|
1538
|
+
for (const pattern of patterns) {
|
|
1539
|
+
let m;
|
|
1540
|
+
while ((m = pattern.exec(err.message)) !== null) {
|
|
1541
|
+
staleOrderIds.add(m[1]);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (/Cannot deduct all or more from order than order contains/.test(err.message)) {
|
|
1545
|
+
return await bot._recoverBatchSizeDrift(err, opContexts);
|
|
1546
|
+
}
|
|
1547
|
+
if (staleOrderIds.size > 0) {
|
|
1548
|
+
return await bot._recoverExplicitStaleOrders(staleOrderIds, 'cow-stale-order-cleanup');
|
|
1549
|
+
}
|
|
1550
|
+
throw err;
|
|
1551
|
+
}
|
|
1552
|
+
finally {
|
|
1553
|
+
bot._batchInFlight = false;
|
|
1554
|
+
bot._markGridActivity('batch end');
|
|
1555
|
+
bot.manager.unlockOrders(idsToLock);
|
|
1556
|
+
if (!bot._shuttingDown && bot._incomingFillQueue.length > 0) {
|
|
1557
|
+
bot._scheduleFillConsumerRestart(chainOrders);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Process results from batch transaction execution.
|
|
1563
|
+
* Updates order state, synchronizes with chain, and deducts BTS fees.
|
|
1564
|
+
* @param {import('./dexbot_class').DEXBot} bot
|
|
1565
|
+
* @param {Object} result
|
|
1566
|
+
* @param {Array} opContexts
|
|
1567
|
+
* @returns {Object} Result with { executed: boolean, hadRotation: boolean }
|
|
1568
|
+
*/
|
|
1569
|
+
async function processBatchResults(bot, result, opContexts) {
|
|
1570
|
+
const results = extractOperationResults(result, 'processBatchResults', bot.manager?.logger?.log?.bind(bot.manager?.logger));
|
|
1571
|
+
const btsFeeData = getAssetFees('BTS');
|
|
1572
|
+
let hadRotation = false;
|
|
1573
|
+
let updateOperationCount = 0;
|
|
1574
|
+
const updatesToApply = [];
|
|
1575
|
+
for (let i = 0; i < opContexts.length; i++) {
|
|
1576
|
+
const ctx = opContexts[i];
|
|
1577
|
+
const res = results[i];
|
|
1578
|
+
if (ctx.kind === 'cancel') {
|
|
1579
|
+
bot.manager.logger.log(`Cancelled surplus order ${ctx.order.id} (${ctx.order.orderId})`, 'info');
|
|
1580
|
+
const oldOrder = ctx.order;
|
|
1581
|
+
const committedOrder = oldOrder?.id ? bot.manager.orders.get(oldOrder.id) : null;
|
|
1582
|
+
if (oldOrder && committedOrder && bot.manager.accountant) {
|
|
1583
|
+
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, committedOrder, 'fill-cancel', btsFeeData?.cancelFee || 0, false);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
else if (ctx.kind === 'size-update') {
|
|
1587
|
+
const oldOrder = ctx.updateInfo.partialOrder;
|
|
1588
|
+
const ord = bot.manager.orders.get(oldOrder.id);
|
|
1589
|
+
if (oldOrder && ord && bot.manager.accountant) {
|
|
1590
|
+
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData.updateFee, false);
|
|
1591
|
+
}
|
|
1592
|
+
if (ord) {
|
|
1593
|
+
const updatedSlot = { ...ord, size: ctx.updateInfo.newSize };
|
|
1594
|
+
if (ctx.finalInts) {
|
|
1595
|
+
updatedSlot.rawOnChain = {
|
|
1596
|
+
id: ord.orderId,
|
|
1597
|
+
for_sale: String(ctx.finalInts.sell),
|
|
1598
|
+
sell_price: {
|
|
1599
|
+
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
1600
|
+
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
|
|
1605
|
+
}
|
|
1606
|
+
bot.manager.logger.log(`Size update complete: ${ctx.updateInfo.partialOrder.orderId}`, 'info');
|
|
1607
|
+
updateOperationCount++;
|
|
1608
|
+
}
|
|
1609
|
+
else if (ctx.kind === 'create') {
|
|
1610
|
+
const chainOrderId = res && res[1];
|
|
1611
|
+
if (chainOrderId) {
|
|
1612
|
+
await bot.manager.synchronizeWithChain({
|
|
1613
|
+
gridOrderId: ctx.order.id, chainOrderId, expectedType: ctx.order.type, fee: btsFeeData.createFee
|
|
1614
|
+
}, 'createOrder');
|
|
1615
|
+
if (ctx.finalInts) {
|
|
1616
|
+
const syncedOrder = bot.manager.orders.get(ctx.order.id);
|
|
1617
|
+
if (syncedOrder) {
|
|
1618
|
+
updatesToApply.push({
|
|
1619
|
+
order: {
|
|
1620
|
+
...syncedOrder,
|
|
1621
|
+
rawOnChain: {
|
|
1622
|
+
id: chainOrderId,
|
|
1623
|
+
for_sale: String(ctx.finalInts.sell),
|
|
1624
|
+
sell_price: {
|
|
1625
|
+
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
1626
|
+
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
context: 'post-placement-metadata'
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
bot.manager.logger.log(`Placed ${ctx.order.type} order ${ctx.order.id} -> ${chainOrderId}`, 'info');
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
const fingerprint = [
|
|
1638
|
+
`type=${ctx.order.type || 'unknown'}`,
|
|
1639
|
+
`price=${Format.formatPrice6(ctx.order.price)}`,
|
|
1640
|
+
`size=${Format.formatAmount(ctx.order.size)}`
|
|
1641
|
+
].join(',');
|
|
1642
|
+
bot.manager.logger.log(`[COW] CRITICAL: Create op for slot ${ctx.order.id} (type=${ctx.order.type}) ` +
|
|
1643
|
+
`returned no chainOrderId. Identify any orphaned on-chain order by local fingerprint ` +
|
|
1644
|
+
`${fingerprint} before cancelling.`, 'error');
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
else if (ctx.kind === 'rotation') {
|
|
1648
|
+
hadRotation = true;
|
|
1649
|
+
const { rotation } = ctx;
|
|
1650
|
+
const { oldOrder, newPrice, newGridId, newSize, type } = rotation;
|
|
1651
|
+
if (!newGridId) {
|
|
1652
|
+
const ord = bot.manager.orders.get(oldOrder.id || rotation.id);
|
|
1653
|
+
if (oldOrder && ord && bot.manager.accountant) {
|
|
1654
|
+
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, ord, 'order-update', btsFeeData.updateFee, false);
|
|
1655
|
+
}
|
|
1656
|
+
if (ord) {
|
|
1657
|
+
const updatedSlot = { ...ord, size: newSize };
|
|
1658
|
+
if (ctx.finalInts) {
|
|
1659
|
+
updatedSlot.rawOnChain = {
|
|
1660
|
+
id: ord.orderId,
|
|
1661
|
+
for_sale: String(ctx.finalInts.sell),
|
|
1662
|
+
sell_price: {
|
|
1663
|
+
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
1664
|
+
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
updatesToApply.push({ order: updatedSlot, context: 'post-update-metadata' });
|
|
1669
|
+
}
|
|
1670
|
+
updateOperationCount++;
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
const slot = bot.manager.orders.get(newGridId);
|
|
1674
|
+
if (!slot) {
|
|
1675
|
+
bot.manager.logger.log(`[ROTATION] Destination slot ${newGridId} missing from master grid after COW commit - skipping activation, sync will reconcile`, 'error');
|
|
1676
|
+
if (oldOrder?.id && oldOrder.id !== newGridId) {
|
|
1677
|
+
const staleSource = bot.manager.orders.get(oldOrder.id);
|
|
1678
|
+
if (staleSource?.orderId) {
|
|
1679
|
+
updatesToApply.push({
|
|
1680
|
+
order: { ...staleSource, state: ORDER_STATES.VIRTUAL, orderId: null, rawOnChain: null },
|
|
1681
|
+
context: 'post-rotation-source-clear'
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
const updatedSlot = {
|
|
1688
|
+
...slot,
|
|
1689
|
+
id: newGridId,
|
|
1690
|
+
type,
|
|
1691
|
+
size: newSize,
|
|
1692
|
+
price: newPrice,
|
|
1693
|
+
state: ORDER_STATES.ACTIVE,
|
|
1694
|
+
orderId: oldOrder?.orderId || slot.orderId || null
|
|
1695
|
+
};
|
|
1696
|
+
if (ctx.finalInts) {
|
|
1697
|
+
updatedSlot.rawOnChain = {
|
|
1698
|
+
id: updatedSlot.orderId,
|
|
1699
|
+
for_sale: String(ctx.finalInts.sell),
|
|
1700
|
+
sell_price: {
|
|
1701
|
+
base: { amount: String(ctx.finalInts.sell), asset_id: ctx.finalInts.sellAssetId },
|
|
1702
|
+
quote: { amount: String(ctx.finalInts.receive), asset_id: ctx.finalInts.receiveAssetId }
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
if (oldOrder && updatedSlot && bot.manager.accountant) {
|
|
1707
|
+
await bot.manager.accountant.updateOptimisticFreeBalance(oldOrder, updatedSlot, 'order-update', btsFeeData.updateFee, false);
|
|
1708
|
+
}
|
|
1709
|
+
if (oldOrder?.id && oldOrder.id !== newGridId) {
|
|
1710
|
+
const currentSource = bot.manager.orders.get(oldOrder.id);
|
|
1711
|
+
if (currentSource && currentSource.orderId) {
|
|
1712
|
+
updatesToApply.push({
|
|
1713
|
+
order: {
|
|
1714
|
+
...currentSource,
|
|
1715
|
+
state: ORDER_STATES.VIRTUAL,
|
|
1716
|
+
orderId: null,
|
|
1717
|
+
rawOnChain: null
|
|
1718
|
+
},
|
|
1719
|
+
context: 'post-rotation-source-clear'
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
updatesToApply.push({ order: updatedSlot, context: 'post-rotation-metadata' });
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (updatesToApply.length > 0) {
|
|
1727
|
+
await bot.manager.applyGridUpdateBatch(updatesToApply.map(u => u.order), 'batch-results-process', { skipAccounting: true });
|
|
1728
|
+
}
|
|
1729
|
+
return {
|
|
1730
|
+
executed: true,
|
|
1731
|
+
hadRotation,
|
|
1732
|
+
updateOperationCount
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
module.exports = {
|
|
1736
|
+
buildOutsideInPairGroupsForOrders,
|
|
1737
|
+
buildOutsideInPairGroupsForCreateEntries,
|
|
1738
|
+
extractOperationResults,
|
|
1739
|
+
findMissingCreateResultContexts,
|
|
1740
|
+
recoverAfterMissingCreateResults,
|
|
1741
|
+
preserveMissingCreateBlockersAfterRecovery,
|
|
1742
|
+
markMissingCreateResultsAsStructuralBlocker,
|
|
1743
|
+
formatUnmatchedChainOrderForLog,
|
|
1744
|
+
recordPendingBroadcast,
|
|
1745
|
+
clearPendingBroadcasts,
|
|
1746
|
+
buildChainOrderFingerprint,
|
|
1747
|
+
normalizeChainOrderForPendingMatch,
|
|
1748
|
+
findChainOrderForSlot,
|
|
1749
|
+
reconcileAfterUncertainBroadcast,
|
|
1750
|
+
reconcileAfterUncertainBroadcastImpl,
|
|
1751
|
+
autoCancelOneUnmatchedOrphan,
|
|
1752
|
+
shouldExecuteCreatePairMode,
|
|
1753
|
+
executeWithRetryOnUncertain,
|
|
1754
|
+
executeOperationsWithStrategy,
|
|
1755
|
+
validateOperationFunds,
|
|
1756
|
+
resolveIdealSizeForValidation,
|
|
1757
|
+
validateOrderSizeForExecution,
|
|
1758
|
+
buildActionsFromPlan,
|
|
1759
|
+
buildCowResultFromPlan,
|
|
1760
|
+
restoreSkippedUpdateSlotsInWorkingGrid,
|
|
1761
|
+
updateOrdersOnChainBatchCOW,
|
|
1762
|
+
processBatchResults,
|
|
1763
|
+
};
|
|
1764
|
+
//# sourceMappingURL=dexbot_cow_runtime.js.map
|