@runesx/api-client 0.4.0 → 0.5.2
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/package.json +7 -7
- package/src/api.mjs +73 -0
- package/src/index.mjs +19 -3
- package/src/socket.mjs +53 -0
- package/src/store/coinStore.mjs +7 -1
- package/src/store/exchangeConfigStore.mjs +61 -0
- package/src/store/marketStore.mjs +57 -0
- package/src/store/orderbookStore.mjs +99 -0
- package/src/store/poolStore.mjs +1 -1
- package/src/store/userSharesStore.mjs +1 -1
- package/src/store/walletStore.mjs +1 -1
- package/src/utils/liquidityUtils.mjs +2 -3
- package/src/utils/priceUtils.mjs +2 -1
- package/src/utils/safeBigNumber.mjs +11 -0
- package/src/utils/swapUtils.mjs +1159 -129
- package/src/waitForStores.mjs +41 -6
- package/src/workers/swapWorker.mjs +2 -2
package/src/utils/swapUtils.mjs
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
// src/utils/swapUtils.mjs
|
|
2
|
-
|
|
2
|
+
// Uses SafeBigNumber (DECIMAL_PLACES=40, EXPONENTIAL_AT=[-100,100]) aligned
|
|
3
|
+
// with the backend's SafeBigNumber to ensure estimation arithmetic matches
|
|
4
|
+
// the backend's rounding behavior. (Audit fix L3, 2026-03-17)
|
|
5
|
+
import { SafeBigNumber as BigNumber } from './safeBigNumber.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get a coin's decimal places, requiring it to be explicitly defined.
|
|
9
|
+
* Unlike `coin.dp || 8`, this correctly handles dp=0 (which is falsy in JS).
|
|
10
|
+
* @param {Object} coin - Coin object with a dp property
|
|
11
|
+
* @returns {number}
|
|
12
|
+
*/
|
|
13
|
+
const getDp = (coin) => {
|
|
14
|
+
if (coin.dp === null || coin.dp === undefined || typeof coin.dp !== 'number') {
|
|
15
|
+
throw new Error(`Coin ${coin.ticker || 'unknown'} is missing dp (decimal places)`);
|
|
16
|
+
}
|
|
17
|
+
return coin.dp;
|
|
18
|
+
};
|
|
3
19
|
|
|
4
20
|
export const validatePositiveNumber = (value, fieldName) => {
|
|
5
21
|
const num = new BigNumber(value);
|
|
@@ -10,23 +26,56 @@ export const validatePositiveNumber = (value, fieldName) => {
|
|
|
10
26
|
};
|
|
11
27
|
|
|
12
28
|
// Get RUNES price in USD using RUNES/USDC pool
|
|
29
|
+
// ── Pool lookup helper (audit fix L3, 2026-03-18) ──
|
|
30
|
+
// Builds a Map keyed by sorted ticker pair ("RUNES-USDC") for O(1) lookups.
|
|
31
|
+
// Replaces O(n) Array.find() in getRunesPriceUSD and getTokenPriceInRunes,
|
|
32
|
+
// which are called per-hop (up to 10×) per path (up to 20 paths) per estimate.
|
|
33
|
+
// The Map is built once per pools array reference — callers that pass the same
|
|
34
|
+
// array (or updatedPools from estimatePath) reuse the cached Map.
|
|
35
|
+
let _poolMapCache = null;
|
|
36
|
+
let _poolMapSource = null;
|
|
37
|
+
let _poolMapSourceLen = -1;
|
|
38
|
+
|
|
39
|
+
function getPoolByTickers(pools, tickerA, tickerB) {
|
|
40
|
+
// Rebuild Map when the pools array changes. Three invalidation signals:
|
|
41
|
+
// 1. Reference equality (primary): Immer/Redux produces a new reference on every state update
|
|
42
|
+
// 2. Length change: catches spread/slice copies where pools were added/removed
|
|
43
|
+
// 3. Always rebuild for spread/slice copies (reference !== source): these are
|
|
44
|
+
// created by estimatePath with mutated reserves from prior hops. The length
|
|
45
|
+
// check alone misses in-place reserve updates (same length, different content).
|
|
46
|
+
//
|
|
47
|
+
// Performance: O(pools) rebuild is cheap (~50-100 pools). The Map avoids O(n)
|
|
48
|
+
// per-call linear scans in getRunesPriceUSD and getTokenPriceInRunes.
|
|
49
|
+
// (Audit fix L2, 2026-03-18; strengthened L6, 2026-03-18)
|
|
50
|
+
if (_poolMapSource !== pools || _poolMapSourceLen !== pools.length) {
|
|
51
|
+
_poolMapCache = new Map();
|
|
52
|
+
for (const p of pools) {
|
|
53
|
+
if (!p || !p.coinA || !p.coinB) {continue;}
|
|
54
|
+
const key = [p.coinA.ticker, p.coinB.ticker].sort().join('-');
|
|
55
|
+
_poolMapCache.set(key, p);
|
|
56
|
+
}
|
|
57
|
+
_poolMapSource = pools;
|
|
58
|
+
_poolMapSourceLen = pools.length;
|
|
59
|
+
}
|
|
60
|
+
const key = [tickerA, tickerB].sort().join('-');
|
|
61
|
+
return _poolMapCache.get(key) || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
13
64
|
export const getRunesPriceUSD = (pools) => {
|
|
14
65
|
try {
|
|
15
|
-
const runesUsdcPool = pools
|
|
16
|
-
(p.coinA.ticker === 'RUNES' && p.coinB.ticker === 'USDC') ||
|
|
17
|
-
(p.coinA.ticker === 'USDC' && p.coinB.ticker === 'RUNES'));
|
|
66
|
+
const runesUsdcPool = getPoolByTickers(pools, 'RUNES', 'USDC');
|
|
18
67
|
|
|
19
68
|
if (!runesUsdcPool) {
|
|
20
|
-
console.warn('RUNES/USDC pool not found
|
|
21
|
-
return '0
|
|
69
|
+
console.warn('RUNES/USDC pool not found');
|
|
70
|
+
return '0';
|
|
22
71
|
}
|
|
23
72
|
|
|
24
73
|
const reserveA = new BigNumber(runesUsdcPool.reserveA).shiftedBy(-runesUsdcPool.coinA.dp);
|
|
25
74
|
const reserveB = new BigNumber(runesUsdcPool.reserveB).shiftedBy(-runesUsdcPool.coinB.dp);
|
|
26
75
|
|
|
27
76
|
if (reserveA.isZero() || reserveB.isZero()) {
|
|
28
|
-
console.warn('RUNES/USDC pool has zero reserves
|
|
29
|
-
return '0
|
|
77
|
+
console.warn('RUNES/USDC pool has zero reserves');
|
|
78
|
+
return '0';
|
|
30
79
|
}
|
|
31
80
|
|
|
32
81
|
let runesPriceUSD;
|
|
@@ -37,25 +86,21 @@ export const getRunesPriceUSD = (pools) => {
|
|
|
37
86
|
}
|
|
38
87
|
|
|
39
88
|
if (runesPriceUSD.isNaN() || runesPriceUSD.lte(0)) {
|
|
40
|
-
console.warn('Invalid RUNES/USDC price calculated
|
|
41
|
-
return '0
|
|
89
|
+
console.warn('Invalid RUNES/USDC price calculated');
|
|
90
|
+
return '0';
|
|
42
91
|
}
|
|
43
92
|
|
|
44
93
|
return runesPriceUSD.toString();
|
|
45
94
|
} catch (error) {
|
|
46
95
|
console.error('Error calculating RUNES/USD price:', error);
|
|
47
|
-
return '0
|
|
96
|
+
return '0';
|
|
48
97
|
}
|
|
49
98
|
};
|
|
50
99
|
|
|
51
100
|
// Get token price in RUNES
|
|
52
101
|
export const getTokenPriceInRunes = (token, pools) => {
|
|
53
102
|
if (token.ticker === 'RUNES') {return '1';}
|
|
54
|
-
const pool = pools.
|
|
55
|
-
(p) =>
|
|
56
|
-
(p.coinA.ticker === 'RUNES' && p.coinB.ticker === token.ticker) ||
|
|
57
|
-
(p.coinB.ticker === 'RUNES' && p.coinA.ticker === token.ticker)
|
|
58
|
-
);
|
|
103
|
+
const pool = getPoolByTickers(pools, 'RUNES', token.ticker);
|
|
59
104
|
if (!pool || new BigNumber(pool.reserveA).isZero() || new BigNumber(pool.reserveB).isZero()) {
|
|
60
105
|
return '0';
|
|
61
106
|
}
|
|
@@ -71,23 +116,78 @@ export const getTokenPriceInRunes = (token, pools) => {
|
|
|
71
116
|
return priceInRunes;
|
|
72
117
|
};
|
|
73
118
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Build unified adjacency map from pools + orderbooks.
|
|
121
|
+
* Each edge is keyed by a unique edgeKey (pool ID or "ob:PAIR") to prevent revisiting.
|
|
122
|
+
*/
|
|
123
|
+
const buildAdjacencyMap = (pools, coins, orderbooks) => {
|
|
124
|
+
const adjMap = new Map(); // ticker -> [{ edgeKey, nextCoin }]
|
|
125
|
+
const edgePairs = new Set(); // track "TICKERA-TICKERB" pairs already added
|
|
78
126
|
|
|
79
|
-
// Build
|
|
80
|
-
|
|
127
|
+
// Build ticker→coin Map for O(1) lookups (Audit fix L2, 2026-03-18).
|
|
128
|
+
// Previously used coins.find() per orderbook pair — O(pairs * coins).
|
|
129
|
+
const coinByUpperTicker = new Map();
|
|
130
|
+
for (const c of coins) {
|
|
131
|
+
coinByUpperTicker.set(c.ticker.toUpperCase(), c);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Add edges from pools
|
|
81
135
|
pools.forEach((pool) => {
|
|
82
136
|
if (!pool.runesCompliant || new BigNumber(pool.reserveA).isZero() || new BigNumber(pool.reserveB).isZero()) {return;}
|
|
83
137
|
const keyA = pool.coinA.ticker;
|
|
84
138
|
const keyB = pool.coinB.ticker;
|
|
85
|
-
if (!
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
|
|
139
|
+
if (!adjMap.has(keyA)) {adjMap.set(keyA, []);}
|
|
140
|
+
if (!adjMap.has(keyB)) {adjMap.set(keyB, []);}
|
|
141
|
+
adjMap.get(keyA).push({ edgeKey: pool.id, nextCoin: pool.coinB });
|
|
142
|
+
adjMap.get(keyB).push({ edgeKey: pool.id, nextCoin: pool.coinA });
|
|
143
|
+
const sorted = [keyA, keyB].sort();
|
|
144
|
+
edgePairs.add(`${sorted[0]}-${sorted[1]}`);
|
|
89
145
|
});
|
|
90
146
|
|
|
147
|
+
// Add edges from orderbooks (only for pairs not already covered by a pool)
|
|
148
|
+
if (orderbooks) {
|
|
149
|
+
for (const pair of Object.keys(orderbooks)) {
|
|
150
|
+
if (edgePairs.has(pair)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const depth = orderbooks[pair];
|
|
154
|
+
if ((!depth.bids || depth.bids.length === 0) && (!depth.asks || depth.asks.length === 0)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parts = pair.split('-');
|
|
159
|
+
if (parts.length !== 2) {continue;}
|
|
160
|
+
// Uppercase tickers for case-insensitive matching: pool edges use
|
|
161
|
+
// pool.coinA.ticker (already uppercase from DB), but legacy coin data
|
|
162
|
+
// could have lowercase tickers. Without uppercasing, a coin with ticker
|
|
163
|
+
// 'runes' wouldn't match the pool edge keyed by 'RUNES', creating a
|
|
164
|
+
// disconnected graph node. (Audit fix L2, 2026-03-18)
|
|
165
|
+
const tickerA = parts[0].toUpperCase();
|
|
166
|
+
const tickerB = parts[1].toUpperCase();
|
|
167
|
+
const coinA = coinByUpperTicker.get(tickerA);
|
|
168
|
+
const coinB = coinByUpperTicker.get(tickerB);
|
|
169
|
+
if (!coinA || !coinB) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const edgeKey = `ob:${pair}`;
|
|
174
|
+
if (!adjMap.has(coinA.ticker)) {adjMap.set(coinA.ticker, []);}
|
|
175
|
+
if (!adjMap.has(coinB.ticker)) {adjMap.set(coinB.ticker, []);}
|
|
176
|
+
adjMap.get(coinA.ticker).push({ edgeKey, nextCoin: coinB });
|
|
177
|
+
adjMap.get(coinB.ticker).push({ edgeKey, nextCoin: coinA });
|
|
178
|
+
edgePairs.add(pair);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return adjMap;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// DFS-based pathfinding
|
|
186
|
+
const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6, coins = [], orderbooks = null) => {
|
|
187
|
+
const paths = [];
|
|
188
|
+
const visited = new Set();
|
|
189
|
+
const adjMap = buildAdjacencyMap(pools, coins, orderbooks);
|
|
190
|
+
|
|
91
191
|
function dfs(currentCoin, currentPath, hops) {
|
|
92
192
|
if (hops > maxHops) {return;}
|
|
93
193
|
if (currentCoin.ticker === endCoin.ticker) {
|
|
@@ -95,12 +195,10 @@ const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6) => {
|
|
|
95
195
|
return;
|
|
96
196
|
}
|
|
97
197
|
|
|
98
|
-
const
|
|
99
|
-
for (const {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (visited.has(poolKey)) {continue;}
|
|
103
|
-
visited.add(poolKey);
|
|
198
|
+
const edges = adjMap.get(currentCoin.ticker) || [];
|
|
199
|
+
for (const { edgeKey, nextCoin } of edges) {
|
|
200
|
+
if (visited.has(edgeKey)) {continue;}
|
|
201
|
+
visited.add(edgeKey);
|
|
104
202
|
|
|
105
203
|
currentPath.push({
|
|
106
204
|
from: currentCoin.ticker,
|
|
@@ -110,7 +208,7 @@ const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6) => {
|
|
|
110
208
|
dfs(nextCoin, currentPath, hops + 1);
|
|
111
209
|
|
|
112
210
|
currentPath.pop();
|
|
113
|
-
visited.delete(
|
|
211
|
+
visited.delete(edgeKey);
|
|
114
212
|
}
|
|
115
213
|
}
|
|
116
214
|
|
|
@@ -119,41 +217,33 @@ const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6) => {
|
|
|
119
217
|
};
|
|
120
218
|
|
|
121
219
|
// BFS-based pathfinding
|
|
122
|
-
|
|
220
|
+
// Each queue entry carries its own visited-edges set so that different branches
|
|
221
|
+
// can independently traverse the same edge — matching DFS backtracking semantics.
|
|
222
|
+
const findAllPathsBFS = (startCoin, endCoin, pools, maxHops = 6, maxPaths = 20, coins = [], orderbooks = null) => {
|
|
123
223
|
const paths = [];
|
|
124
|
-
const queue = [{ coin: startCoin, path: [], hops: 0 }];
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
// Build pool map for faster lookup
|
|
128
|
-
const poolMap = new Map();
|
|
129
|
-
pools.forEach((pool) => {
|
|
130
|
-
if (!pool.runesCompliant || new BigNumber(pool.reserveA).isZero() || new BigNumber(pool.reserveB).isZero()) {return;}
|
|
131
|
-
const keyA = pool.coinA.ticker;
|
|
132
|
-
const keyB = pool.coinB.ticker;
|
|
133
|
-
if (!poolMap.has(keyA)) {poolMap.set(keyA, []);}
|
|
134
|
-
if (!poolMap.has(keyB)) {poolMap.set(keyB, []);}
|
|
135
|
-
poolMap.get(keyA).push({ pool, nextCoin: pool.coinB });
|
|
136
|
-
poolMap.get(keyB).push({ pool, nextCoin: pool.coinA });
|
|
137
|
-
});
|
|
224
|
+
const queue = [{ coin: startCoin, path: [], hops: 0, visitedEdges: new Set() }];
|
|
225
|
+
const adjMap = buildAdjacencyMap(pools, coins, orderbooks);
|
|
138
226
|
|
|
139
227
|
while (queue.length && paths.length < maxPaths) {
|
|
140
|
-
const { coin, path, hops } = queue.shift();
|
|
228
|
+
const { coin, path, hops, visitedEdges } = queue.shift();
|
|
141
229
|
if (hops > maxHops) {continue;}
|
|
142
230
|
if (coin.ticker === endCoin.ticker) {
|
|
143
231
|
paths.push(path);
|
|
144
232
|
continue;
|
|
145
233
|
}
|
|
146
234
|
|
|
147
|
-
const
|
|
148
|
-
for (const {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
235
|
+
const edges = adjMap.get(coin.ticker) || [];
|
|
236
|
+
for (const { edgeKey, nextCoin } of edges) {
|
|
237
|
+
if (visitedEdges.has(edgeKey)) {continue;}
|
|
238
|
+
|
|
239
|
+
const newVisited = new Set(visitedEdges);
|
|
240
|
+
newVisited.add(edgeKey);
|
|
152
241
|
|
|
153
242
|
queue.push({
|
|
154
243
|
coin: nextCoin,
|
|
155
244
|
path: [...path, { from: coin.ticker, to: nextCoin.ticker }],
|
|
156
245
|
hops: hops + 1,
|
|
246
|
+
visitedEdges: newVisited,
|
|
157
247
|
});
|
|
158
248
|
}
|
|
159
249
|
}
|
|
@@ -162,17 +252,27 @@ const findAllPathsBFS = (startCoin, endCoin, pools, maxHops = 6, maxPaths = 20)
|
|
|
162
252
|
};
|
|
163
253
|
|
|
164
254
|
// Find all possible swap paths (select DFS or BFS)
|
|
165
|
-
|
|
255
|
+
// coins + orderbooks are optional — when provided, orderbook-only pairs are included in pathfinding
|
|
256
|
+
export const findAllPaths = (startCoin, endCoin, pools, maxHops = 6, algorithm = 'dfs', coins = [], orderbooks = null) => {
|
|
166
257
|
if (algorithm === 'bfs') {
|
|
167
|
-
return findAllPathsBFS(startCoin, endCoin, pools, maxHops, 20);
|
|
258
|
+
return findAllPathsBFS(startCoin, endCoin, pools, maxHops, 20, coins, orderbooks);
|
|
168
259
|
}
|
|
169
|
-
return findAllPathsDFS(startCoin, endCoin, pools, maxHops);
|
|
260
|
+
return findAllPathsDFS(startCoin, endCoin, pools, maxHops, coins, orderbooks);
|
|
170
261
|
};
|
|
171
262
|
|
|
172
263
|
// Simulate a single swap in a pool (aligned with backend's swapSingle)
|
|
173
264
|
export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
|
|
174
265
|
if (!pool.runesCompliant) {return null;}
|
|
175
266
|
|
|
267
|
+
// Validate dp from socket-delivered pool data. A corrupted payload with
|
|
268
|
+
// dp outside [0,18] would produce astronomically wrong estimates via
|
|
269
|
+
// shiftedBy(). Matches backend swapSingle dp validation range.
|
|
270
|
+
// (Audit fix L3, 2026-03-18; supports dp=0 for indivisible tokens, audit B, 2026-03-18)
|
|
271
|
+
const coinADp = pool.coinA?.dp;
|
|
272
|
+
const coinBDp = pool.coinB?.dp;
|
|
273
|
+
if (!Number.isInteger(coinADp) || coinADp < 0 || coinADp > 18
|
|
274
|
+
|| !Number.isInteger(coinBDp) || coinBDp < 0 || coinBDp > 18) {return null;}
|
|
275
|
+
|
|
176
276
|
if (amountInBN.lt(1)) {return null;}
|
|
177
277
|
|
|
178
278
|
const reserveABN = new BigNumber(pool.reserveA);
|
|
@@ -183,12 +283,15 @@ export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
|
|
|
183
283
|
const treasuryFeeRate = new BigNumber(pool.treasuryFeeRate).div(100);
|
|
184
284
|
const totalFeeRate = lpFeeRate.plus(treasuryFeeRate);
|
|
185
285
|
|
|
286
|
+
// Fee rate validation: matches backend computeAmmFees() in swapCore.mjs.
|
|
287
|
+
// Without this, a corrupted pool fee rate from the socket could produce
|
|
288
|
+
// nonsensical estimates (negative output, NaN, etc). (Audit fix L6, 2026-03-18)
|
|
289
|
+
if (lpFeeRate.isNaN() || !lpFeeRate.isFinite() || lpFeeRate.lt(0)
|
|
290
|
+
|| treasuryFeeRate.isNaN() || !treasuryFeeRate.isFinite() || treasuryFeeRate.lt(0)
|
|
291
|
+
|| totalFeeRate.gte(1) || totalFeeRate.gt('0.05')) {return null;}
|
|
292
|
+
|
|
186
293
|
// Calculate total fee and split proportionally
|
|
187
|
-
|
|
188
|
-
// Minimum fee floor: match backend - ensure at least 1 smallest unit when fees are configured
|
|
189
|
-
if (totalFeeRate.gt(0) && totalFeeAmount.isZero()) {
|
|
190
|
-
totalFeeAmount = new BigNumber(1);
|
|
191
|
-
}
|
|
294
|
+
const totalFeeAmount = amountInBN.times(totalFeeRate).integerValue(BigNumber.ROUND_DOWN);
|
|
192
295
|
let treasuryFeeAmount = new BigNumber(0);
|
|
193
296
|
let lpFeeAmount = new BigNumber(0);
|
|
194
297
|
if (!totalFeeRate.eq(0)) {
|
|
@@ -199,9 +302,6 @@ export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
|
|
|
199
302
|
const amountInForPool = amountInBN.minus(totalFeeAmount);
|
|
200
303
|
if (amountInForPool.lt(1)) {return null;}
|
|
201
304
|
|
|
202
|
-
// const outputCoin = isCoinAInput ? pool.coinB : pool.coinA;
|
|
203
|
-
// const outputCoinDp = outputCoin.dp || 8;
|
|
204
|
-
|
|
205
305
|
let amountOutBN;
|
|
206
306
|
if (isCoinAInput) {
|
|
207
307
|
amountOutBN = amountInForPool.times(reserveBBN).div(reserveABN.plus(amountInForPool));
|
|
@@ -212,6 +312,15 @@ export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
|
|
|
212
312
|
|
|
213
313
|
if (amountOutBN.isNaN() || !amountOutBN.isFinite() || amountOutBN.lt(1)) {return null;}
|
|
214
314
|
|
|
315
|
+
// MIN_RESERVE_THRESHOLD: matches backend swapSingle's check in swapCore.mjs.
|
|
316
|
+
// Without this, the frontend could show estimates for swaps that the backend
|
|
317
|
+
// would reject due to insufficient post-swap reserves. (Audit fix L4, 2026-03-18)
|
|
318
|
+
const MIN_RESERVE_THRESHOLD = new BigNumber(1000);
|
|
319
|
+
const newResOut = isCoinAInput
|
|
320
|
+
? reserveBBN.minus(amountOutBN)
|
|
321
|
+
: reserveABN.minus(amountOutBN);
|
|
322
|
+
if (newResOut.lt(MIN_RESERVE_THRESHOLD)) {return null;}
|
|
323
|
+
|
|
215
324
|
// Simulate reserve updates (mimicking backend)
|
|
216
325
|
const updatedPool = { ...pool };
|
|
217
326
|
if (isCoinAInput) {
|
|
@@ -230,63 +339,923 @@ export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
|
|
|
230
339
|
};
|
|
231
340
|
};
|
|
232
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Simulate filling an order book for a given input amount.
|
|
344
|
+
* Matches backend fillFromOrderBook.mjs math: operates in whole units with
|
|
345
|
+
* per-level integer rounding and per-level fee deductions.
|
|
346
|
+
*
|
|
347
|
+
* For a buy (inputCoin is quote, outputCoin is base): walk asks (ascending price).
|
|
348
|
+
* For a sell (inputCoin is base, outputCoin is quote): walk bids (descending price).
|
|
349
|
+
*
|
|
350
|
+
* @param {object} depth - { bids: [[price, qty], ...], asks: [[price, qty], ...] } (human-readable values)
|
|
351
|
+
* @param {'buy'|'sell'} side - 'buy' means spending inputCoin to acquire outputCoin
|
|
352
|
+
* @param {BigNumber} amountIn - whole-unit input amount
|
|
353
|
+
* @param {number} inputDp - decimal places of input coin
|
|
354
|
+
* @param {number} outputDp - decimal places of output coin
|
|
355
|
+
* @returns {{ amountOut: BigNumber }|null}
|
|
356
|
+
*/
|
|
357
|
+
// Default hard cap on total orders the backend will lock across all batches.
|
|
358
|
+
// Overridden at runtime by exchange_config from the backend socket event.
|
|
359
|
+
const DEFAULT_MAX_CLOB_FILL_TOTAL = 500;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Calculate a CLOB fee as floor(amount * rate).
|
|
363
|
+
* Mirrors backend clobFees.mjs calculateClobFee() exactly.
|
|
364
|
+
* When the amount is small enough that floor rounds to zero, the fee is zero.
|
|
365
|
+
* Minimum trade/swap amounts prevent economically insignificant fills.
|
|
366
|
+
*
|
|
367
|
+
* @param {BigNumber} amount - Whole-unit amount the fee is charged on
|
|
368
|
+
* @param {BigNumber} rate - Fee rate (e.g. 0.002 for taker)
|
|
369
|
+
* @returns {BigNumber} Fee in whole units
|
|
370
|
+
*/
|
|
371
|
+
const calculateClobFee = (amount, rate) => {
|
|
372
|
+
return amount.times(rate).integerValue(BigNumber.ROUND_DOWN);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// SECURITY NOTE (audit H3, 2026-03-18): This function operates on depth data
|
|
376
|
+
// delivered via WebSocket. A compromised or MITM'd socket could inject inflated
|
|
377
|
+
// per-order sizes or fake price levels. The backend re-evaluates all CLOB fills
|
|
378
|
+
// independently with FOR UPDATE locks inside a SERIALIZABLE transaction — this
|
|
379
|
+
// simulation is purely advisory for UI estimation and minAmountOut derivation.
|
|
380
|
+
// The per-order size sum validation below (H3 fix) catches gross payload corruption,
|
|
381
|
+
// but subtle manipulation (redistributing sizes within tolerance) is accepted —
|
|
382
|
+
// the backend's independent execution + minAmountOut slippage protection are the
|
|
383
|
+
// authoritative safeguards.
|
|
384
|
+
export const simulateClobFill = (depth, side, amountIn, inputDp, outputDp, userOrders = null, pair = null, clobFees = null) => {
|
|
385
|
+
// side = 'buy': we spend quote coin, walk asks, receive base coin
|
|
386
|
+
// side = 'sell': we spend base coin, walk bids, receive quote coin
|
|
387
|
+
const levels = side === 'buy' ? (depth.asks || []) : (depth.bids || []);
|
|
388
|
+
if (levels.length === 0) {return null;}
|
|
389
|
+
|
|
390
|
+
// Price deviation guard (audit fix M6, 2026-03-17):
|
|
391
|
+
// Matches backend fillFromOrderBook.mjs and matchingEngine.mjs — if the
|
|
392
|
+
// current fill price deviates by more than maxPriceDeviation from the best
|
|
393
|
+
// (first) fill price, stop filling. Without this, the frontend simulation
|
|
394
|
+
// could overstate CLOB output when the real fill would stop early due to
|
|
395
|
+
// price deviation, causing the user to set an unrealistically high
|
|
396
|
+
// minAmountOut that fails on execution.
|
|
397
|
+
// Fallback '5' matches backend MAX_CLOB_PRICE_DEVIATION_FACTOR in config/clobFees.mjs.
|
|
398
|
+
// The authoritative value is delivered via exchange_config socket event.
|
|
399
|
+
// (Aligned: audit fix H4, 2026-03-18 — tightened from '10' to '5')
|
|
400
|
+
const MAX_PRICE_DEVIATION = new BigNumber(clobFees?.maxPriceDeviation || '5');
|
|
401
|
+
|
|
402
|
+
// Self-trade prevention: the backend (fillFromOrderBook.mjs) walks orders
|
|
403
|
+
// in price-time priority and stops when it encounters the user's own order,
|
|
404
|
+
// filling valid non-user orders BEFORE it. We simulate this using exact user
|
|
405
|
+
// order data (available from the orderbook Redux store via socket):
|
|
406
|
+
//
|
|
407
|
+
// - At price levels BEFORE the user's first resting order: fill normally
|
|
408
|
+
// - AT the boundary level: fill non-user orders only (quantity reduced by
|
|
409
|
+
// user's resting quantity, order count reduced by user's order count).
|
|
410
|
+
// This is a lower-bound approximation since we don't know the exact
|
|
411
|
+
// time-priority ordering of user vs non-user orders within the level.
|
|
412
|
+
// - At price levels BEYOND the boundary: stop entirely
|
|
413
|
+
//
|
|
414
|
+
// (Audit fix M5, 2026-03-16; aligned with backend H5, 2026-03-17)
|
|
415
|
+
let selfTradePriceBN = null;
|
|
416
|
+
let userQtyByPrice = null; // price -> total remaining qty (BigNumber)
|
|
417
|
+
let userRemainingsByPrice = null; // price -> [remaining1, remaining2, ...] (human-readable strings for per-order matching)
|
|
418
|
+
if (userOrders && pair) {
|
|
419
|
+
const oppositeSide = side === 'buy' ? 'sell' : 'buy';
|
|
420
|
+
const userOppositeOrders = userOrders.filter(
|
|
421
|
+
(o) => o.pair === pair && o.side === oppositeSide && ['open', 'partially_filled'].includes(o.status)
|
|
422
|
+
&& new BigNumber(o.quantity).minus(o.filledQuantity || '0').gt(0)
|
|
423
|
+
);
|
|
424
|
+
if (userOppositeOrders.length > 0) {
|
|
425
|
+
// Build map of user's remaining quantity at each price (aggregate + individual)
|
|
426
|
+
userQtyByPrice = new Map();
|
|
427
|
+
userRemainingsByPrice = new Map();
|
|
428
|
+
for (const o of userOppositeOrders) {
|
|
429
|
+
const remaining = new BigNumber(o.quantity).minus(o.filledQuantity || '0');
|
|
430
|
+
const priceKey = new BigNumber(o.price).toString();
|
|
431
|
+
const existing = userQtyByPrice.get(priceKey) || new BigNumber(0);
|
|
432
|
+
userQtyByPrice.set(priceKey, existing.plus(remaining));
|
|
433
|
+
// Track individual remaining quantities for per-order size matching at
|
|
434
|
+
// self-trade boundary levels. (Audit fix L2, 2026-03-17)
|
|
435
|
+
if (!userRemainingsByPrice.has(priceKey)) {userRemainingsByPrice.set(priceKey, []);}
|
|
436
|
+
userRemainingsByPrice.get(priceKey).push(remaining.toString());
|
|
437
|
+
}
|
|
438
|
+
// Best (first-encountered) self-trade price determines the boundary.
|
|
439
|
+
// For buy side (walking asks ASC): user's lowest ask price.
|
|
440
|
+
// For sell side (walking bids DESC): user's highest bid price.
|
|
441
|
+
// Use reduce instead of BigNumber.min/max(...spread) to avoid creating
|
|
442
|
+
// a large argument list for users with many opposite-side orders.
|
|
443
|
+
// (Audit fix L2, 2026-03-18)
|
|
444
|
+
const userPrices = userOppositeOrders.map((o) => new BigNumber(o.price));
|
|
445
|
+
selfTradePriceBN = userPrices.reduce((best, p) =>
|
|
446
|
+
(side === 'buy' ? (p.lt(best) ? p : best) : (p.gt(best) ? p : best)),
|
|
447
|
+
userPrices[0],
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Fee rate from backend config (delivered via exchange_config socket event).
|
|
453
|
+
// Falls back to 0.2% if config hasn't arrived yet — matches current backend default.
|
|
454
|
+
const TAKER_FEE_RATE = new BigNumber(clobFees?.takerFeeRate || '0.002');
|
|
455
|
+
|
|
456
|
+
// Depth values are human-readable. Convert to whole units for integer math
|
|
457
|
+
// matching the backend's per-level rounding behavior.
|
|
458
|
+
// baseDp is always outputDp for buy, inputDp for sell.
|
|
459
|
+
const baseDp = side === 'buy' ? outputDp : inputDp;
|
|
460
|
+
|
|
461
|
+
const maxFillTotal = clobFees?.maxFillTotal || DEFAULT_MAX_CLOB_FILL_TOTAL;
|
|
462
|
+
|
|
463
|
+
let remainingInput = new BigNumber(amountIn); // already in whole units
|
|
464
|
+
let totalNetOutput = new BigNumber(0);
|
|
465
|
+
// totalRowsExamined tracks ALL orders walked (including dust-skipped), matching
|
|
466
|
+
// the backend's totalRowsLocked counter in fillFromOrderBook.mjs which counts
|
|
467
|
+
// all rows locked FOR UPDATE regardless of whether they produce a fill. Without
|
|
468
|
+
// this, the simulation could walk more orders than the backend allows, overstating
|
|
469
|
+
// CLOB output when many orders are dust-skipped. (Audit fix M4, 2026-03-17)
|
|
470
|
+
let totalRowsExamined = 0;
|
|
471
|
+
// Track cumulative per-order sizes received. The backend's getDepthSnapshot
|
|
472
|
+
// caps per-order sizes at MAX_CLOB_FILL_TOTAL across all levels (the
|
|
473
|
+
// `totalOrderSizesEmitted` counter in OrderBook.getDepthSnapshot). Once this
|
|
474
|
+
// cap is reached, subsequent levels have no perOrderSizes and the frontend
|
|
475
|
+
// falls back to equal-split. This is slightly less accurate for deep books
|
|
476
|
+
// but matches the backend's fill behavior: it won't lock more than
|
|
477
|
+
// MAX_CLOB_FILL_TOTAL orders anyway, so the missing per-order data is
|
|
478
|
+
// beyond the simulation boundary. (Audit fix L5, 2026-03-17)
|
|
479
|
+
let cumulativePerOrderSizesReceived = 0;
|
|
480
|
+
// Track whether the simulation used the equal-split fallback for any level.
|
|
481
|
+
// When true, the estimate may diverge from actual execution because equal-
|
|
482
|
+
// splitting assumes uniform order sizes, which can differ significantly from
|
|
483
|
+
// reality. The caller can use this to display a lower-confidence indicator.
|
|
484
|
+
// (Audit fix M4, 2026-03-17)
|
|
485
|
+
let usedEqualSplitFallback = false;
|
|
486
|
+
// Track how much of the total output came from equal-split fallback levels
|
|
487
|
+
// (no per-order sizes). When this fraction is high, the estimate may diverge
|
|
488
|
+
// significantly from actual execution. The caller can use this to display a
|
|
489
|
+
// quantitative confidence metric. (Audit fix M3, 2026-03-18)
|
|
490
|
+
let fallbackOutput = new BigNumber(0);
|
|
491
|
+
|
|
492
|
+
// Price deviation guard: track the first (best) fill price to detect
|
|
493
|
+
// catastrophic slippage through stale or manipulated deep-book orders.
|
|
494
|
+
// (Audit fix M6, 2026-03-17 — aligned with backend)
|
|
495
|
+
let bestFillPrice = null;
|
|
496
|
+
|
|
497
|
+
// Cumulative rounding trackers: instead of rounding each fill independently
|
|
498
|
+
// (which causes sum(ceil) > ceil(sum) drift), track the cumulative exact quote
|
|
499
|
+
// amount and derive each fill's integer consumption from the delta between
|
|
500
|
+
// consecutive cumulative ceils. Matches backend fillFromOrderBook.mjs logic.
|
|
501
|
+
//
|
|
502
|
+
// NOTE: The backend also tracks per-maker cumulative rounding (makerCumulativeExact)
|
|
503
|
+
// for maker lock accounting. We intentionally omit that here because it only affects
|
|
504
|
+
// how much locked balance each maker loses — it does NOT affect taker output or
|
|
505
|
+
// input consumed. The taker-side tracker below is sufficient for estimation.
|
|
506
|
+
let cumulativeExactQuote = new BigNumber(0);
|
|
507
|
+
let prevCumulativeCeil = new BigNumber(0);
|
|
508
|
+
|
|
509
|
+
for (const level of levels) {
|
|
510
|
+
if (remainingInput.lte(0)) {break;}
|
|
511
|
+
|
|
512
|
+
const [priceStr, qtyStr, orderCount, ...perOrderSizes] = level;
|
|
513
|
+
|
|
514
|
+
const levelOrders = orderCount || 1;
|
|
515
|
+
// NOTE: Order count cap is now checked per-order inside the inner loop
|
|
516
|
+
// (audit fix M1, 2026-03-16) to match backend behavior. The backend fills
|
|
517
|
+
// individual orders up to MAX_CLOB_FILL_TOTAL — it doesn't skip an entire
|
|
518
|
+
// level when the level's order count would exceed the cap. The previous
|
|
519
|
+
// per-level check understated CLOB output when a large level straddled
|
|
520
|
+
// the cap boundary.
|
|
521
|
+
const priceHuman = new BigNumber(priceStr);
|
|
522
|
+
|
|
523
|
+
// Self-trade prevention: levels beyond the user's best opposite order
|
|
524
|
+
// are unreachable (the backend stops at the user's own order).
|
|
525
|
+
// At the boundary level, subtract the user's own quantity and order count —
|
|
526
|
+
// the backend fills non-user orders at the same price before encountering
|
|
527
|
+
// the user's. (Audit fix M5, 2026-03-16; aligned H5, 2026-03-17)
|
|
528
|
+
let isSelfTradeLevel = false;
|
|
529
|
+
if (selfTradePriceBN) {
|
|
530
|
+
if (side === 'buy' && priceHuman.gt(selfTradePriceBN)) {break;}
|
|
531
|
+
if (side === 'sell' && priceHuman.lt(selfTradePriceBN)) {break;}
|
|
532
|
+
isSelfTradeLevel = priceHuman.eq(selfTradePriceBN);
|
|
533
|
+
}
|
|
534
|
+
let qtyHuman = new BigNumber(qtyStr);
|
|
535
|
+
if (isSelfTradeLevel && userQtyByPrice) {
|
|
536
|
+
const userQty = userQtyByPrice.get(priceHuman.toString());
|
|
537
|
+
if (userQty && userQty.gt(0)) {
|
|
538
|
+
qtyHuman = qtyHuman.minus(userQty);
|
|
539
|
+
if (qtyHuman.lte(0)) {break;} // entire level is user's orders
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Guard against NaN/non-finite values from corrupted socket payloads.
|
|
543
|
+
// BigNumber(NaN).lte(0) returns false, so NaN would silently propagate
|
|
544
|
+
// through all downstream arithmetic — corrupting totalNetOutput and
|
|
545
|
+
// bypassing the final lte(0) return check. Explicit NaN/finite guards
|
|
546
|
+
// match backend matchingEngine.mjs:133. (Audit fix H1, 2026-03-18)
|
|
547
|
+
if (priceHuman.isNaN() || !priceHuman.isFinite() || priceHuman.lte(0)
|
|
548
|
+
|| qtyHuman.isNaN() || !qtyHuman.isFinite() || qtyHuman.lte(0)) {continue;}
|
|
549
|
+
|
|
550
|
+
// Price deviation guard (audit fix M6, 2026-03-17):
|
|
551
|
+
// Matches backend fillFromOrderBook.mjs — if the current level's price
|
|
552
|
+
// deviates by more than MAX_PRICE_DEVIATION from the best (first) fill
|
|
553
|
+
// price, stop filling. For buy side (asks sorted ASC), later levels have
|
|
554
|
+
// higher price (worse for buyer). For sell side (bids sorted DESC), later
|
|
555
|
+
// levels have lower price (worse for seller).
|
|
556
|
+
if (!bestFillPrice) {
|
|
557
|
+
bestFillPrice = priceHuman;
|
|
558
|
+
} else {
|
|
559
|
+
const deviation = side === 'buy'
|
|
560
|
+
? priceHuman.div(bestFillPrice)
|
|
561
|
+
: bestFillPrice.div(priceHuman);
|
|
562
|
+
if (deviation.gt(MAX_PRICE_DEVIATION)) {break;}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Convert level values to whole units (matching backend representation)
|
|
566
|
+
// price_whole = price_human * 10^quoteDp where quoteDp = (buy ? inputDp : outputDp)
|
|
567
|
+
const quoteDp = side === 'buy' ? inputDp : outputDp;
|
|
568
|
+
const makerPrice = priceHuman.shiftedBy(quoteDp); // price in whole units
|
|
569
|
+
const levelQty = qtyHuman.shiftedBy(baseDp); // quantity in base whole units
|
|
570
|
+
|
|
571
|
+
// quoteAmount = fillQty * makerPrice / 10^baseDp (backend formula)
|
|
572
|
+
const quoteForLevel = (qty) => qty.times(makerPrice).shiftedBy(-baseDp);
|
|
573
|
+
|
|
574
|
+
// Build per-order quantities for this level.
|
|
575
|
+
// When the backend provides actual per-order sizes (indices 3+ in the depth
|
|
576
|
+
// entry), use them for accurate fill simulation. Otherwise, fall back to
|
|
577
|
+
// equal-size splitting (less accurate but backwards-compatible).
|
|
578
|
+
//
|
|
579
|
+
// Self-trade boundary levels (aligned with backend, audit fix H5, 2026-03-17):
|
|
580
|
+
// The backend walks orders in price-time priority and stops at the FIRST
|
|
581
|
+
// user order encountered. At the boundary level, it fills non-user orders
|
|
582
|
+
// that appear before the user's order. We approximate this by using the
|
|
583
|
+
// adjusted level quantity (qtyHuman, already reduced by the user's quantity
|
|
584
|
+
// above) and distributing it across the non-user order count. This is a
|
|
585
|
+
// lower-bound estimate (assumes user orders are interspersed, not at the
|
|
586
|
+
// end), but more accurate than the previous approach which skipped the
|
|
587
|
+
// entire level.
|
|
588
|
+
const ordersAtLevel = levelOrders;
|
|
589
|
+
|
|
590
|
+
// Count of user's orders at this price (for boundary level order count adjustment)
|
|
591
|
+
let userOrderCountAtLevel = 0;
|
|
592
|
+
if (isSelfTradeLevel && userOrders && pair) {
|
|
593
|
+
const oppositeSide = side === 'buy' ? 'sell' : 'buy';
|
|
594
|
+
userOrderCountAtLevel = userOrders.filter(
|
|
595
|
+
(o) => o.pair === pair && o.side === oppositeSide
|
|
596
|
+
&& ['open', 'partially_filled'].includes(o.status)
|
|
597
|
+
&& new BigNumber(o.quantity).minus(o.filledQuantity || '0').gt(0)
|
|
598
|
+
&& new BigNumber(o.price).eq(priceHuman),
|
|
599
|
+
).length;
|
|
600
|
+
}
|
|
601
|
+
// Effective non-user order count at this level
|
|
602
|
+
const effectiveOrderCount = isSelfTradeLevel
|
|
603
|
+
? Math.max(0, ordersAtLevel - userOrderCountAtLevel)
|
|
604
|
+
: ordersAtLevel;
|
|
605
|
+
|
|
606
|
+
let orderQuantities;
|
|
607
|
+
// Track whether this level used equal-split so we can attribute its output
|
|
608
|
+
// to the fallback fraction. (Audit fix M3, 2026-03-18)
|
|
609
|
+
let levelUsedFallback = false;
|
|
610
|
+
if (perOrderSizes.length > 0 && perOrderSizes.length === ordersAtLevel) {
|
|
611
|
+
// Validate that per-order sizes sum to the level's total quantity.
|
|
612
|
+
// A corrupted socket payload (MITM, bug) could inject inflated sizes,
|
|
613
|
+
// overstating CLOB output and causing the user to set an unrealistic
|
|
614
|
+
// minAmountOut. Fall back to equal-split on mismatch. (Audit fix H3, 2026-03-18)
|
|
615
|
+
const perOrderSum = perOrderSizes.reduce(
|
|
616
|
+
(acc, s) => acc.plus(new BigNumber(s)), new BigNumber(0),
|
|
617
|
+
);
|
|
618
|
+
// Compare in human-readable space (same as qtyStr) to avoid dp conversion
|
|
619
|
+
const levelQtyHuman = new BigNumber(qtyStr);
|
|
620
|
+
const perOrderSizesValid = perOrderSum.gt(0)
|
|
621
|
+
&& perOrderSum.minus(levelQtyHuman).abs().lte(levelQtyHuman.times('0.001').plus(1e-18));
|
|
622
|
+
|
|
623
|
+
if (!perOrderSizesValid) {
|
|
624
|
+
// Mismatch — fall back to equal-split for this level
|
|
625
|
+
if (perOrderSum.gt(0)) {
|
|
626
|
+
console.warn(
|
|
627
|
+
`simulateClobFill: perOrderSizes sum (${perOrderSum.toString()}) does not match `
|
|
628
|
+
+ `level qty (${qtyStr}) at price ${priceStr} — falling back to equal split`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const splitCount = isSelfTradeLevel ? effectiveOrderCount : ordersAtLevel;
|
|
632
|
+
if (splitCount <= 0 || levelQty.lte(0)) {
|
|
633
|
+
orderQuantities = [];
|
|
634
|
+
} else {
|
|
635
|
+
const perOrderQty = levelQty.div(splitCount).integerValue(BigNumber.ROUND_DOWN);
|
|
636
|
+
const lastOrderQty = levelQty.minus(perOrderQty.times(splitCount - 1));
|
|
637
|
+
orderQuantities = [];
|
|
638
|
+
for (let i = 0; i < splitCount; i += 1) {
|
|
639
|
+
orderQuantities.push(i < splitCount - 1 ? perOrderQty : lastOrderQty);
|
|
640
|
+
}
|
|
641
|
+
usedEqualSplitFallback = true;
|
|
642
|
+
levelUsedFallback = true;
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
// Per-order sizes are valid — use them
|
|
646
|
+
cumulativePerOrderSizesReceived += perOrderSizes.length;
|
|
647
|
+
if (!isSelfTradeLevel) {
|
|
648
|
+
// Non-boundary level: use per-order sizes directly
|
|
649
|
+
orderQuantities = perOrderSizes.map((s) => new BigNumber(s).shiftedBy(baseDp));
|
|
650
|
+
} else {
|
|
651
|
+
// Self-trade boundary level: the backend walks orders in price-time
|
|
652
|
+
// priority and stops at the FIRST user order encountered. We have
|
|
653
|
+
// per-order sizes but don't know their exact time-priority position.
|
|
654
|
+
//
|
|
655
|
+
// Strategy (audit fix L2, 2026-03-17): When individual user order
|
|
656
|
+
// remaining quantities are available, greedily remove matching entries
|
|
657
|
+
// from the per-order size list. This preserves actual non-user order
|
|
658
|
+
// sizes instead of falling back to equal-split, producing a tighter
|
|
659
|
+
// estimate. The match is by quantity value (human-readable string
|
|
660
|
+
// comparison) — if a user order's remaining quantity matches a per-order
|
|
661
|
+
// size, it's likely that entry. Multiple user orders at the same price
|
|
662
|
+
// are handled by consuming one match per user order.
|
|
663
|
+
//
|
|
664
|
+
// KNOWN LIMITATION (audit fix L4, 2026-03-18): This greedy matching
|
|
665
|
+
// is lossy — if a non-user order has the same remaining quantity as a
|
|
666
|
+
// user order, the non-user order could be incorrectly removed from the
|
|
667
|
+
// simulation, understating available liquidity at the self-trade
|
|
668
|
+
// boundary level. This produces a LOWER-BOUND estimate, which is
|
|
669
|
+
// conservative (the user may receive more than estimated, not less).
|
|
670
|
+
// The backend has exact per-order userId knowledge and does not share
|
|
671
|
+
// this limitation. No fix is possible without the backend exposing
|
|
672
|
+
// per-order userId data in the depth snapshot (which would leak
|
|
673
|
+
// trading activity). Accept as inherent estimation approximation.
|
|
674
|
+
const userRemainings = (userRemainingsByPrice && userRemainingsByPrice.get(priceHuman.toString())) || [];
|
|
675
|
+
if (userRemainings.length > 0) {
|
|
676
|
+
// Build a consumable multiset of user remaining quantities
|
|
677
|
+
const userQtyBag = new Map(); // qty string -> count remaining to remove
|
|
678
|
+
for (const uqty of userRemainings) {
|
|
679
|
+
userQtyBag.set(uqty, (userQtyBag.get(uqty) || 0) + 1);
|
|
680
|
+
}
|
|
681
|
+
// Filter per-order sizes: keep entries that don't match a user order.
|
|
682
|
+
//
|
|
683
|
+
// IMPROVEMENT (audit fix M6, 2026-03-18): Track how many entries were
|
|
684
|
+
// removed by greedy matching. If any were removed, flag as low-confidence
|
|
685
|
+
// because a non-user order with the same remaining quantity could have
|
|
686
|
+
// been incorrectly excluded (understating available liquidity). This is
|
|
687
|
+
// a LOWER-BOUND estimate — conservative for the user.
|
|
688
|
+
const filteredSizes = [];
|
|
689
|
+
let greedyMatchCount = 0;
|
|
690
|
+
for (const s of perOrderSizes) {
|
|
691
|
+
const remaining = userQtyBag.get(s) || 0;
|
|
692
|
+
if (remaining > 0) {
|
|
693
|
+
// This per-order size matches a user order — remove it
|
|
694
|
+
userQtyBag.set(s, remaining - 1);
|
|
695
|
+
greedyMatchCount += 1;
|
|
696
|
+
} else {
|
|
697
|
+
filteredSizes.push(s);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
orderQuantities = filteredSizes.map((s) => new BigNumber(s).shiftedBy(baseDp));
|
|
701
|
+
// If greedy matching removed entries, flag low confidence: a non-user
|
|
702
|
+
// order with an identical remaining quantity may have been incorrectly
|
|
703
|
+
// excluded. The estimate is still conservative (lower-bound).
|
|
704
|
+
if (greedyMatchCount > 0) {
|
|
705
|
+
usedEqualSplitFallback = true;
|
|
706
|
+
levelUsedFallback = true;
|
|
707
|
+
}
|
|
708
|
+
} else {
|
|
709
|
+
// No individual user remainders available — fall back to equal-split
|
|
710
|
+
if (effectiveOrderCount <= 0 || levelQty.lte(0)) {
|
|
711
|
+
orderQuantities = [];
|
|
712
|
+
} else {
|
|
713
|
+
const perOrderQty = levelQty.div(effectiveOrderCount).integerValue(BigNumber.ROUND_DOWN);
|
|
714
|
+
const lastOrderQty = levelQty.minus(perOrderQty.times(effectiveOrderCount - 1));
|
|
715
|
+
orderQuantities = [];
|
|
716
|
+
for (let i = 0; i < effectiveOrderCount; i += 1) {
|
|
717
|
+
orderQuantities.push(i < effectiveOrderCount - 1 ? perOrderQty : lastOrderQty);
|
|
718
|
+
}
|
|
719
|
+
usedEqualSplitFallback = true;
|
|
720
|
+
levelUsedFallback = true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} else {
|
|
726
|
+
// No per-order sizes available for this level. Three scenarios:
|
|
727
|
+
//
|
|
728
|
+
// 1. The backend's per-order size cap (MAX_CLOB_FILL_TOTAL) was exceeded —
|
|
729
|
+
// cumulativePerOrderSizesReceived >= maxFillTotal. The backend won't lock
|
|
730
|
+
// more orders anyway, so stop simulating entirely. Previously the frontend
|
|
731
|
+
// used equal-split which diverged from real per-order fills, causing
|
|
732
|
+
// estimation errors and minAmountOut rejections. (Audit fix M3, 2026-03-17)
|
|
733
|
+
//
|
|
734
|
+
// 2. perOrderSizes length doesn't match orderCount — data inconsistency.
|
|
735
|
+
// Fall back to equal split for this level only (rare edge case).
|
|
736
|
+
//
|
|
737
|
+
// 3. No perOrderSizes provided at all (older backend or non-includeOrderSizes
|
|
738
|
+
// depth). Fall back to equal split for all levels (backwards-compatible).
|
|
739
|
+
//
|
|
740
|
+
// Scenario 1 is the common case — once we've consumed all per-order data from
|
|
741
|
+
// the backend, further simulation is beyond the backend's fill boundary and
|
|
742
|
+
// would produce divergent estimates.
|
|
743
|
+
//
|
|
744
|
+
// STRANDED DUST NOTE (audit fix M1, 2026-03-18): The backend's getDepthSnapshot
|
|
745
|
+
// filters permanently stranded dust orders (orders whose full remaining quantity
|
|
746
|
+
// produces zero quote transfer) via a pre-computed `_stranded` flag on each
|
|
747
|
+
// OrderBook entry. This flag is set when _baseDp is provided to the OrderBook
|
|
748
|
+
// constructor, which is always the case (orderBookCache.getOrCreate resolves
|
|
749
|
+
// baseDp from the pair). Therefore, stranded orders should NOT appear in the
|
|
750
|
+
// depth data received via socket. If a legacy backend omits the filter (no
|
|
751
|
+
// _baseDp), the equal-split fallback here may overstate CLOB output for levels
|
|
752
|
+
// containing stranded dust — the usedEqualSplitFallback flag below signals
|
|
753
|
+
// lower confidence to the UI.
|
|
754
|
+
if (cumulativePerOrderSizesReceived > 0 && cumulativePerOrderSizesReceived >= maxFillTotal) {
|
|
755
|
+
// Per-order data exhausted: we've reached the backend's fill cap.
|
|
756
|
+
// Stop simulating — any remaining levels are beyond what the backend
|
|
757
|
+
// will actually fill. This aligns the frontend estimate with the
|
|
758
|
+
// backend's MAX_CLOB_FILL_TOTAL boundary. (Audit fix M3, 2026-03-17)
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
if (perOrderSizes.length > 0 && perOrderSizes.length !== ordersAtLevel) {
|
|
762
|
+
console.warn(`simulateClobFill: perOrderSizes length (${perOrderSizes.length}) does not match orderCount (${ordersAtLevel}) at price ${priceStr} — falling back to equal split`);
|
|
763
|
+
}
|
|
764
|
+
// Use effectiveOrderCount for self-trade boundary levels (adjusted for
|
|
765
|
+
// user's orders), ordersAtLevel otherwise. (Audit fix H5, 2026-03-17)
|
|
766
|
+
const splitCount = isSelfTradeLevel ? effectiveOrderCount : ordersAtLevel;
|
|
767
|
+
if (splitCount <= 0 || levelQty.lte(0)) {
|
|
768
|
+
orderQuantities = [];
|
|
769
|
+
} else {
|
|
770
|
+
const perOrderQty = levelQty.div(splitCount).integerValue(BigNumber.ROUND_DOWN);
|
|
771
|
+
const lastOrderQty = levelQty.minus(perOrderQty.times(splitCount - 1));
|
|
772
|
+
orderQuantities = [];
|
|
773
|
+
for (let i = 0; i < splitCount; i += 1) {
|
|
774
|
+
orderQuantities.push(i < splitCount - 1 ? perOrderQty : lastOrderQty);
|
|
775
|
+
}
|
|
776
|
+
usedEqualSplitFallback = true; // (Audit fix M4, 2026-03-17)
|
|
777
|
+
levelUsedFallback = true;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Use orderQuantities.length (not ordersAtLevel) since self-trade
|
|
782
|
+
// filtering may have reduced the number of orders. (Audit fix M3, 2026-03-17)
|
|
783
|
+
for (let orderIdx = 0; orderIdx < orderQuantities.length; orderIdx += 1) {
|
|
784
|
+
if (remainingInput.lte(0)) {break;}
|
|
785
|
+
// Per-order cap: matches backend's FOR UPDATE LIMIT across all batches.
|
|
786
|
+
// Stranded dust orders (whose remaining qty produces zero quote) are NOT
|
|
787
|
+
// excluded here — they count against the cap, matching the backend fill
|
|
788
|
+
// loop which fetches them via FOR UPDATE before the dust-fill `continue`.
|
|
789
|
+
// The backend auto-cancels stranded orders within the same transaction
|
|
790
|
+
// and removes them from the in-memory book in afterCommit, so subsequent
|
|
791
|
+
// depth snapshots won't include them. (Audit review H3, 2026-03-17)
|
|
792
|
+
// (Audit fix M1, 2026-03-16)
|
|
793
|
+
totalRowsExamined += 1;
|
|
794
|
+
if (totalRowsExamined > maxFillTotal) {break;}
|
|
795
|
+
|
|
796
|
+
const makerQty = orderQuantities[orderIdx];
|
|
797
|
+
// Guard against NaN/non-finite values from corrupted socket payloads.
|
|
798
|
+
// BigNumber(NaN).lte(0) returns false, so NaN would silently propagate
|
|
799
|
+
// through all downstream arithmetic — producing a NaN amountOut that
|
|
800
|
+
// accidentally bypasses the final lte(0) check. Explicit NaN/finite
|
|
801
|
+
// check ensures corrupted entries are skipped cleanly.
|
|
802
|
+
// (Audit fix H3, 2026-03-18)
|
|
803
|
+
if (!makerQty || makerQty.isNaN() || !makerQty.isFinite() || makerQty.lte(0)) {continue;}
|
|
804
|
+
|
|
805
|
+
let fillQty;
|
|
806
|
+
let inputConsumed;
|
|
807
|
+
|
|
808
|
+
if (side === 'buy') {
|
|
809
|
+
// User spends quote (input) whole units, receives base (output) whole units.
|
|
810
|
+
// maxBuyable = remainingInput * 10^baseDp / makerPrice (ROUND_DOWN — conservative for taker)
|
|
811
|
+
const maxBuyable = remainingInput.times(new BigNumber(10).pow(baseDp)).div(makerPrice).integerValue(BigNumber.ROUND_DOWN);
|
|
812
|
+
fillQty = BigNumber.min(maxBuyable, makerQty);
|
|
813
|
+
if (fillQty.lte(0)) {break;}
|
|
814
|
+
// Dust-fill guard: skip fills where the quote transfer rounds to zero.
|
|
815
|
+
// Uses `continue` (not `break`) to allow subsequent orders with larger
|
|
816
|
+
// quantities to produce valid fills — aligned with backend
|
|
817
|
+
// fillFromOrderBook.mjs and placeOrder.mjs. (Audit fix M1, 2026-03-17)
|
|
818
|
+
const quoteAmountExact = quoteForLevel(fillQty);
|
|
819
|
+
const quoteAmountFloor = quoteAmountExact.integerValue(BigNumber.ROUND_DOWN);
|
|
820
|
+
if (quoteAmountFloor.lte(0)) {continue;}
|
|
821
|
+
// Cumulative ceil: derive this fill's cost from the delta between consecutive ceils
|
|
822
|
+
cumulativeExactQuote = cumulativeExactQuote.plus(quoteAmountExact);
|
|
823
|
+
const currentCumulativeCeil = cumulativeExactQuote.integerValue(BigNumber.ROUND_UP);
|
|
824
|
+
inputConsumed = currentCumulativeCeil.minus(prevCumulativeCeil);
|
|
825
|
+
prevCumulativeCeil = currentCumulativeCeil;
|
|
826
|
+
} else {
|
|
827
|
+
// User spends base (input) whole units, receives quote (output) whole units.
|
|
828
|
+
fillQty = BigNumber.min(remainingInput, makerQty);
|
|
829
|
+
if (fillQty.lte(0)) {break;}
|
|
830
|
+
inputConsumed = fillQty;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Per-order output and fee (matching backend per-fill deduction)
|
|
834
|
+
const outputThisFill = side === 'buy'
|
|
835
|
+
? fillQty // taker receives base whole units
|
|
836
|
+
: quoteForLevel(fillQty).integerValue(BigNumber.ROUND_DOWN); // taker receives quote whole units
|
|
837
|
+
|
|
838
|
+
// Dust-fill guard for sell side: quote amount rounds to zero at this price.
|
|
839
|
+
// Uses `continue` to allow subsequent orders to produce valid fills —
|
|
840
|
+
// aligned with backend fillFromOrderBook.mjs. (Audit fix M1, 2026-03-17)
|
|
841
|
+
if (outputThisFill.lte(0)) {continue;}
|
|
842
|
+
|
|
843
|
+
const takerFee = calculateClobFee(outputThisFill, TAKER_FEE_RATE);
|
|
844
|
+
const netOutput = outputThisFill.minus(takerFee);
|
|
845
|
+
|
|
846
|
+
totalNetOutput = totalNetOutput.plus(netOutput);
|
|
847
|
+
if (levelUsedFallback) {fallbackOutput = fallbackOutput.plus(netOutput);}
|
|
848
|
+
remainingInput = remainingInput.minus(inputConsumed);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// After filling at the self-trade boundary level, stop — the backend
|
|
852
|
+
// would encounter the user's own order here and halt further fills.
|
|
853
|
+
if (isSelfTradeLevel) {break;}
|
|
854
|
+
// If per-order cap was hit inside the inner loop, stop outer loop too.
|
|
855
|
+
if (totalRowsExamined > maxFillTotal) {break;}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Final NaN guard: if any corrupted level bypassed per-level validation
|
|
859
|
+
// (e.g., a NaN intermediate value from BigNumber arithmetic edge cases),
|
|
860
|
+
// the accumulated totalNetOutput could be NaN. BigNumber(NaN).lte(0)
|
|
861
|
+
// returns false, so the check below would NOT catch it — returning
|
|
862
|
+
// { amountOut: NaN } to the caller. (Audit fix H1, 2026-03-18)
|
|
863
|
+
if (totalNetOutput.isNaN() || !totalNetOutput.isFinite() || totalNetOutput.lte(0)) {return null;}
|
|
864
|
+
|
|
865
|
+
// totalNetOutput is already in output whole units — return directly
|
|
866
|
+
// inputUsed = total input consumed in whole units (amountIn - remainingInput)
|
|
867
|
+
const inputUsed = new BigNumber(amountIn).minus(remainingInput);
|
|
868
|
+
// fallbackFraction: proportion of total net output that came from equal-split
|
|
869
|
+
// levels (no per-order sizes). 0 = fully accurate, 1 = entirely estimated.
|
|
870
|
+
// The caller can use this to display a quantitative confidence metric.
|
|
871
|
+
// (Audit fix M3, 2026-03-18)
|
|
872
|
+
const fallbackFraction = totalNetOutput.gt(0)
|
|
873
|
+
? fallbackOutput.div(totalNetOutput).toNumber()
|
|
874
|
+
: 0;
|
|
875
|
+
return { amountOut: totalNetOutput, inputUsed, remainingInput, usedEqualSplitFallback, fallbackFraction, totalRowsExamined };
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Derive canonical pair and side for CLOB lookup from a hop step.
|
|
880
|
+
* @param {string} fromTicker
|
|
881
|
+
* @param {string} toTicker
|
|
882
|
+
* @returns {{ pair: string, side: 'buy'|'sell' }}
|
|
883
|
+
*/
|
|
884
|
+
// Default ticker regex — used as fallback until the backend delivers the
|
|
885
|
+
// authoritative pattern via exchange_config. Callers pass the backend's
|
|
886
|
+
// pattern via clobFees.tickerPattern when available (single source of truth).
|
|
887
|
+
// (Audit fix L1, 2026-03-18; replaces hardcoded duplicate M7, 2026-03-18)
|
|
888
|
+
const DEFAULT_TICKER_PATTERN = /^[A-Z0-9]{1,20}$/;
|
|
889
|
+
|
|
890
|
+
// Cache compiled RegExp per pattern string to avoid re-compilation per call.
|
|
891
|
+
// Capped at 20 entries to prevent unbounded growth if a compromised socket
|
|
892
|
+
// delivers many unique patterns. (Audit fix M3, 2026-03-19)
|
|
893
|
+
const _tickerReCache = new Map();
|
|
894
|
+
const MAX_TICKER_RE_CACHE_SIZE = 20;
|
|
895
|
+
function getTickerRe(pattern) {
|
|
896
|
+
if (!pattern) {return DEFAULT_TICKER_PATTERN;}
|
|
897
|
+
let cached = _tickerReCache.get(pattern);
|
|
898
|
+
if (!cached) {
|
|
899
|
+
try {
|
|
900
|
+
cached = new RegExp(pattern);
|
|
901
|
+
} catch {
|
|
902
|
+
cached = DEFAULT_TICKER_PATTERN;
|
|
903
|
+
}
|
|
904
|
+
if (_tickerReCache.size >= MAX_TICKER_RE_CACHE_SIZE) {
|
|
905
|
+
const oldestKey = _tickerReCache.keys().next().value;
|
|
906
|
+
_tickerReCache.delete(oldestKey);
|
|
907
|
+
}
|
|
908
|
+
_tickerReCache.set(pattern, cached);
|
|
909
|
+
}
|
|
910
|
+
return cached;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Derive CLOB pair and side from a hop step.
|
|
915
|
+
* Uses the admin-defined market direction from the markets lookup (if provided),
|
|
916
|
+
* falling back to alphabetical sorting for backward compatibility.
|
|
917
|
+
*
|
|
918
|
+
* @param {string} fromTicker
|
|
919
|
+
* @param {string} toTicker
|
|
920
|
+
* @param {string|null} tickerPattern - Regex pattern for ticker validation
|
|
921
|
+
* @param {Object|null} marketsLookup - Map of sorted coin key → market object from Redux
|
|
922
|
+
* @param {Array|null} coins - Coins array for ID resolution when using marketsLookup
|
|
923
|
+
* @returns {{ pair: string, side: 'buy'|'sell' }|null}
|
|
924
|
+
*/
|
|
925
|
+
const deriveClobPairAndSide = (fromTicker, toTicker, tickerPattern = null, marketsLookup = null, coins = null) => {
|
|
926
|
+
const TICKER_RE = getTickerRe(tickerPattern);
|
|
927
|
+
const from = fromTicker.toUpperCase();
|
|
928
|
+
const to = toTicker.toUpperCase();
|
|
929
|
+
if (!TICKER_RE.test(from) || !TICKER_RE.test(to) || from === to) {
|
|
930
|
+
if (process.env.NODE_ENV === 'development') {
|
|
931
|
+
console.warn(`deriveClobPairAndSide: invalid tickers "${fromTicker}" / "${toTicker}" — CLOB estimation skipped`);
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Try to resolve from admin-defined markets
|
|
937
|
+
if (marketsLookup && coins) {
|
|
938
|
+
const fromCoin = coins.find((c) => c.ticker.toUpperCase() === from);
|
|
939
|
+
const toCoin = coins.find((c) => c.ticker.toUpperCase() === to);
|
|
940
|
+
if (fromCoin && toCoin) {
|
|
941
|
+
const key = fromCoin.id < toCoin.id ? `${fromCoin.id}|${toCoin.id}` : `${toCoin.id}|${fromCoin.id}`;
|
|
942
|
+
const market = marketsLookup[key];
|
|
943
|
+
if (market && market.pair) {
|
|
944
|
+
const parts = market.pair.split('-');
|
|
945
|
+
const baseTicker = parts[0];
|
|
946
|
+
// buy = spending quote to get base, sell = spending base to get quote
|
|
947
|
+
const side = from === baseTicker ? 'sell' : 'buy';
|
|
948
|
+
return { pair: market.pair, side };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Fallback: alphabetical sorting (backward compatible)
|
|
954
|
+
const [first, second] = [from, to].sort();
|
|
955
|
+
const pair = `${first}-${second}`;
|
|
956
|
+
const side = from === first ? 'buy' : 'sell';
|
|
957
|
+
return { pair, side };
|
|
958
|
+
};
|
|
959
|
+
|
|
233
960
|
// Estimate a single path
|
|
234
|
-
export const estimatePath =
|
|
235
|
-
|
|
961
|
+
export const estimatePath = (pools, path, inputCoin, amountIn, coins, orderbooks = null, userOrders = null, clobFees = null, marketsLookup = null) => {
|
|
962
|
+
// ── Path validation (mirrors backend deriveQueueNamesAndPoolConditions) ──
|
|
963
|
+
// Reject paths that the backend would reject, preventing the user from seeing
|
|
964
|
+
// an estimate for an unsubmittable swap. (Audit fix L4, 2026-03-17)
|
|
965
|
+
if (!path || path.length === 0) {return null;}
|
|
966
|
+
// Max path length: aligned with backend executeSwapCore (swapCore.mjs) which
|
|
967
|
+
// rejects paths longer than 10 hops. Without this, a direct call to
|
|
968
|
+
// estimatePath with a crafted long path would execute without bounds,
|
|
969
|
+
// consuming CPU on the client. (Audit fix M5, 2026-03-18)
|
|
970
|
+
if (path.length > 10) {return null;}
|
|
971
|
+
const usedPairs = new Set();
|
|
972
|
+
for (let i = 0; i < path.length; i += 1) {
|
|
973
|
+
const step = path[i];
|
|
974
|
+
if (!step || typeof step.from !== 'string' || typeof step.to !== 'string') {return null;} // invalid step structure
|
|
975
|
+
if (step.from === step.to) {return null;} // self-pair
|
|
976
|
+
if (i > 0 && step.from !== path[i - 1].to) {return null;} // discontinuous
|
|
977
|
+
const [first, second] = [step.from.toUpperCase(), step.to.toUpperCase()].sort();
|
|
978
|
+
const pairKey = `${first}-${second}`;
|
|
979
|
+
if (usedPairs.has(pairKey)) {return null;} // duplicate pair usage
|
|
980
|
+
usedPairs.add(pairKey);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
let currentAmount = new BigNumber(amountIn).shiftedBy(getDp(inputCoin));
|
|
236
984
|
let currentCoin = inputCoin;
|
|
237
|
-
let priceImpact = 0;
|
|
238
985
|
const intermediateAmounts = [];
|
|
986
|
+
const enrichedPath = []; // Path steps enriched with venue decision
|
|
239
987
|
let updatedPools = [...pools]; // Clone pools to simulate reserve updates
|
|
988
|
+
let clobEstimateLowConfidence = false; // Set when CLOB simulation used equal-split fallback (Audit fix M4, 2026-03-17)
|
|
989
|
+
let spilloverEstimateLowConfidence = false; // Set when CLOB+AMM spillover uses potentially stale pool reserves (Audit fix M4, 2026-03-17)
|
|
240
990
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
991
|
+
// Price impact (Option C — audit fix L6, 2026-03-16):
|
|
992
|
+
// Compute the marginal (spot) rate at each hop — what 1 unit of input would
|
|
993
|
+
// receive at the best available price, before any size-dependent slippage.
|
|
994
|
+
// The compound product of per-hop marginal rates gives the ideal end-to-end
|
|
995
|
+
// rate. Comparing that against the actual rate isolates pure size-dependent
|
|
996
|
+
// price impact, correctly compounding across multi-hop paths without double-
|
|
997
|
+
// counting fees (fees affect both marginal and actual rates equally).
|
|
998
|
+
let compoundMarginalRate = new BigNumber(1); // product of per-hop marginal rates (output-whole / input-whole)
|
|
999
|
+
|
|
1000
|
+
// Build pool lookup map for O(1) per-hop resolution instead of O(pools) findIndex.
|
|
1001
|
+
// Key: sorted "TICKERA-TICKERB" → index in updatedPools array.
|
|
1002
|
+
// Rebuilt from updatedPools (not the original pools) so reserve updates from
|
|
1003
|
+
// earlier hops are visible to later hops. (Audit fix L2, 2026-03-18)
|
|
1004
|
+
const buildPoolLookup = (poolList) => {
|
|
1005
|
+
const map = new Map();
|
|
1006
|
+
for (let idx = 0; idx < poolList.length; idx += 1) {
|
|
1007
|
+
const p = poolList[idx];
|
|
1008
|
+
if (!p || !p.coinA || !p.coinB) {continue;}
|
|
1009
|
+
const key = [p.coinA.ticker, p.coinB.ticker].sort().join('-');
|
|
1010
|
+
map.set(key, idx);
|
|
1011
|
+
}
|
|
1012
|
+
return map;
|
|
1013
|
+
};
|
|
1014
|
+
let poolLookup = buildPoolLookup(updatedPools);
|
|
248
1015
|
|
|
249
|
-
|
|
250
|
-
const
|
|
1016
|
+
for (let stepIdx = 0; stepIdx < path.length; stepIdx += 1) {
|
|
1017
|
+
const step = path[stepIdx];
|
|
1018
|
+
const lookupKey = [step.from, step.to].sort().join('-');
|
|
1019
|
+
const poolIndex = poolLookup.get(lookupKey) ?? -1;
|
|
1020
|
+
|
|
1021
|
+
const pool = poolIndex !== -1 ? updatedPools[poolIndex] : null;
|
|
1022
|
+
// Snapshot pre-hop reserves for marginal rate calculation. The venue
|
|
1023
|
+
// selection block below may update updatedPools[poolIndex] (e.g., for
|
|
1024
|
+
// CLOB+AMM spillover), which mutates the pool reference. The marginal
|
|
1025
|
+
// rate must use pre-hop reserves (the spot rate before this hop
|
|
1026
|
+
// executes). (Audit fix M5, 2026-03-18)
|
|
1027
|
+
const preHopReserveA = pool ? pool.reserveA : null;
|
|
1028
|
+
const preHopReserveB = pool ? pool.reserveB : null;
|
|
251
1029
|
const outputCoin = coins.find((c) => c.ticker === step.to);
|
|
252
1030
|
if (!outputCoin) {return null;}
|
|
253
1031
|
|
|
254
|
-
const currentCoinDp = currentCoin
|
|
1032
|
+
const currentCoinDp = getDp(currentCoin);
|
|
1033
|
+
const outputCoinDp = getDp(outputCoin);
|
|
255
1034
|
const smallestUnit = new BigNumber(1).shiftedBy(-currentCoinDp);
|
|
256
1035
|
if (new BigNumber(currentAmount).shiftedBy(-currentCoinDp).lt(smallestUnit)) {
|
|
257
1036
|
return null;
|
|
258
1037
|
}
|
|
259
1038
|
|
|
260
|
-
|
|
261
|
-
|
|
1039
|
+
// Cap intermediate amounts to prevent excessive BigNumber arithmetic from
|
|
1040
|
+
// hanging the client. Matches the backend's 1e25 whole-unit cap in
|
|
1041
|
+
// fillFromOrderBook.mjs and placeOrder.mjs. An extreme intermediate amount
|
|
1042
|
+
// (from a manipulated pool reserve in the socket payload) could cause
|
|
1043
|
+
// BigNumber operations to take excessive time. (Audit fix M12, 2026-03-18)
|
|
1044
|
+
const MAX_INTERMEDIATE_WHOLE = new BigNumber('1e30');
|
|
1045
|
+
if (new BigNumber(currentAmount).gt(MAX_INTERMEDIATE_WHOLE)) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
262
1048
|
|
|
263
|
-
|
|
1049
|
+
// For intermediate hops, validate against per-coin swap minimum input.
|
|
1050
|
+
// The backend rejects swaps where any intermediate amount is below the
|
|
1051
|
+
// coin's swapMinimumInput. Checking here prevents showing an estimate
|
|
1052
|
+
// that would be rejected on submission. (Audit fix S7, 2026-03-16)
|
|
1053
|
+
if (stepIdx > 0 && currentCoin.swapMinimumInput) {
|
|
1054
|
+
const swapMin = new BigNumber(currentCoin.swapMinimumInput);
|
|
1055
|
+
const currentAmountHuman = new BigNumber(currentAmount).shiftedBy(-currentCoinDp);
|
|
1056
|
+
if (swapMin.gt(0) && currentAmountHuman.lt(swapMin)) {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
264
1060
|
|
|
265
|
-
//
|
|
266
|
-
|
|
1061
|
+
// AMM simulation (only if pool exists and is compliant)
|
|
1062
|
+
let ammOut = null;
|
|
1063
|
+
let swapResult = null;
|
|
1064
|
+
if (pool && pool.runesCompliant) {
|
|
1065
|
+
const isCoinAInput = pool.coinA.ticker === step.from;
|
|
1066
|
+
swapResult = simulateSwap(pool, currentCoin, currentAmount, isCoinAInput);
|
|
1067
|
+
if (swapResult && swapResult.amountOut.gt(0)) {
|
|
1068
|
+
ammOut = swapResult.amountOut;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
267
1071
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
1072
|
+
// CLOB simulation
|
|
1073
|
+
let clobOut = null;
|
|
1074
|
+
let clobResult = null;
|
|
1075
|
+
if (orderbooks) {
|
|
1076
|
+
const clobPairInfo = deriveClobPairAndSide(step.from, step.to, clobFees?.tickerPattern, marketsLookup, coins);
|
|
1077
|
+
if (clobPairInfo) {
|
|
1078
|
+
const { pair: clobPair, side: clobSide } = clobPairInfo;
|
|
1079
|
+
const depth = orderbooks[clobPair];
|
|
1080
|
+
if (depth) {
|
|
1081
|
+
clobResult = simulateClobFill(depth, clobSide, currentAmount, currentCoinDp, outputCoinDp, userOrders, clobPair, clobFees);
|
|
1082
|
+
if (clobResult && clobResult.amountOut.gt(0)) {
|
|
1083
|
+
clobOut = clobResult.amountOut;
|
|
1084
|
+
if (clobResult.usedEqualSplitFallback) {clobEstimateLowConfidence = true;}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Pick best venue, with CLOB+AMM spillover support.
|
|
1091
|
+
// The backend (swapCore.mjs) can split a first-hop across CLOB and AMM when
|
|
1092
|
+
// CLOB only partially fills the input. We simulate this here:
|
|
1093
|
+
// 1. If CLOB gives better output AND fully consumes input → pure CLOB
|
|
1094
|
+
// 2. If CLOB gives better output but partially fills → CLOB output + AMM on remainder
|
|
1095
|
+
// 3. Otherwise → pure AMM (or pure CLOB if no pool)
|
|
1096
|
+
let amountOutBN;
|
|
1097
|
+
let venue;
|
|
1098
|
+
if (ammOut && clobOut) {
|
|
1099
|
+
// Check if CLOB fully consumed the input
|
|
1100
|
+
const clobFullyFilled = clobResult && clobResult.remainingInput && clobResult.remainingInput.lte(0);
|
|
1101
|
+
|
|
1102
|
+
if (clobOut.gt(ammOut)) {
|
|
1103
|
+
// CLOB is better — use it
|
|
1104
|
+
if (clobFullyFilled) {
|
|
1105
|
+
amountOutBN = clobOut;
|
|
1106
|
+
venue = 'clob';
|
|
1107
|
+
} else if (pool && pool.runesCompliant && clobResult.remainingInput.gt(0)) {
|
|
1108
|
+
// CLOB partial fill + AMM spillover: the backend handles this for all
|
|
1109
|
+
// hops (swapCore.mjs routes the unconsumed CLOB remainder through AMM
|
|
1110
|
+
// at every hop, not just the first). (Comment corrected, audit fix M6, 2026-03-17)
|
|
1111
|
+
const isCoinAInput = pool.coinA.ticker === step.from;
|
|
1112
|
+
const remainderResult = simulateSwap(pool, currentCoin, clobResult.remainingInput, isCoinAInput);
|
|
1113
|
+
if (remainderResult && remainderResult.amountOut.gt(0)) {
|
|
1114
|
+
// Apply a conservative 1% discount to the AMM spillover portion.
|
|
1115
|
+
// The AMM remainder estimate uses pool reserves from the last socket
|
|
1116
|
+
// update, which may be stale by seconds. In volatile markets this can
|
|
1117
|
+
// cause the combined estimate to diverge from actual execution (where
|
|
1118
|
+
// the backend uses reserves locked FOR UPDATE). The discount prevents
|
|
1119
|
+
// the user's derived minAmountOut from being set too aggressively,
|
|
1120
|
+
// reducing slippage failures on execution.
|
|
1121
|
+
// (Audit fix M1, 2026-03-18; supplements M4 low-confidence flag)
|
|
1122
|
+
const spilloverDiscount = new BigNumber('0.99');
|
|
1123
|
+
const discountedAmmOut = remainderResult.amountOut.times(spilloverDiscount).integerValue(BigNumber.ROUND_DOWN);
|
|
1124
|
+
amountOutBN = clobOut.plus(discountedAmmOut);
|
|
1125
|
+
venue = 'clob+amm';
|
|
1126
|
+
spilloverEstimateLowConfidence = true;
|
|
1127
|
+
updatedPools[poolIndex] = remainderResult.updatedPool;
|
|
1128
|
+
} else {
|
|
1129
|
+
// AMM can't handle remainder — compare pure CLOB (partial) vs pure AMM (full)
|
|
1130
|
+
if (clobOut.gt(ammOut)) {
|
|
1131
|
+
// Even partial CLOB beats full AMM — but backend would reject on
|
|
1132
|
+
// intermediate hops. For first hop, backend also rejects if no pool.
|
|
1133
|
+
// Use AMM as it processes the full amount.
|
|
1134
|
+
amountOutBN = ammOut;
|
|
1135
|
+
venue = 'amm';
|
|
1136
|
+
updatedPools[poolIndex] = swapResult.updatedPool;
|
|
1137
|
+
} else {
|
|
1138
|
+
amountOutBN = ammOut;
|
|
1139
|
+
venue = 'amm';
|
|
1140
|
+
updatedPools[poolIndex] = swapResult.updatedPool;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
} else {
|
|
1144
|
+
// Partial CLOB fill, no pool for spillover — pure CLOB still better
|
|
1145
|
+
// Note: backend would reject this on first hop without a pool, but for
|
|
1146
|
+
// estimation we show the best possible output. The actual execution will
|
|
1147
|
+
// fail with an appropriate error if liquidity is insufficient.
|
|
1148
|
+
amountOutBN = clobOut;
|
|
1149
|
+
venue = 'clob';
|
|
1150
|
+
}
|
|
1151
|
+
} else {
|
|
1152
|
+
amountOutBN = ammOut;
|
|
1153
|
+
venue = 'amm';
|
|
1154
|
+
updatedPools[poolIndex] = swapResult.updatedPool;
|
|
1155
|
+
}
|
|
1156
|
+
} else if (ammOut) {
|
|
1157
|
+
amountOutBN = ammOut;
|
|
1158
|
+
venue = 'amm';
|
|
1159
|
+
updatedPools[poolIndex] = swapResult.updatedPool;
|
|
1160
|
+
} else if (clobOut) {
|
|
1161
|
+
amountOutBN = clobOut;
|
|
1162
|
+
venue = 'clob';
|
|
1163
|
+
} else {
|
|
1164
|
+
return null; // No liquidity from either venue
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ── Per-hop marginal rate for price impact (audit fix L6) ──
|
|
1168
|
+
// Compute the output a hypothetically tiny trade would receive at the
|
|
1169
|
+
// current spot/best price. This is the "zero-size" rate — the rate before
|
|
1170
|
+
// any size-dependent slippage. All values are in whole units so the ratio
|
|
1171
|
+
// is unit-consistent across hops regardless of decimal places.
|
|
1172
|
+
//
|
|
1173
|
+
// AMM: marginal rate = reserveOut / reserveIn (constant-product spot price,
|
|
1174
|
+
// already accounts for fee structure since fees are deducted from input
|
|
1175
|
+
// before the x*y=k calculation — a tiny trade has the same fee rate).
|
|
1176
|
+
// CLOB: marginal rate = output from 1 unit at best price level (with fees).
|
|
1177
|
+
// For buy: 1 quote-whole buys (10^baseDp / bestPrice) base-whole.
|
|
1178
|
+
// For sell: 1 base-whole receives (bestPrice / 10^baseDp) quote-whole.
|
|
1179
|
+
// Taker fee is deducted from output, matching the actual fill path.
|
|
1180
|
+
let hopMarginalRate = null;
|
|
1181
|
+
|
|
1182
|
+
if (pool && pool.runesCompliant && (venue === 'amm' || venue === 'clob+amm')) {
|
|
1183
|
+
// AMM marginal rate from pre-hop reserves. Uses the snapshotted values
|
|
1184
|
+
// captured BEFORE venue selection (preHopReserveA/B) so that CLOB+AMM
|
|
1185
|
+
// spillover — which updates updatedPools[poolIndex] — cannot affect the
|
|
1186
|
+
// marginal rate computation. The marginal rate should reflect the spot
|
|
1187
|
+
// price BEFORE this hop executes, not after partial reserve consumption.
|
|
1188
|
+
// (Audit fix M5, 2026-03-18)
|
|
1189
|
+
const isCoinAInput = pool.coinA.ticker === step.from;
|
|
1190
|
+
const reserveA = new BigNumber(preHopReserveA);
|
|
1191
|
+
const reserveB = new BigNumber(preHopReserveB);
|
|
1192
|
+
if (!reserveA.isZero() && !reserveB.isZero()) {
|
|
1193
|
+
// Marginal rate in output-whole per input-whole (after fees)
|
|
1194
|
+
const totalFeeRate = new BigNumber(pool.lpFeeRate).plus(pool.treasuryFeeRate).div(100);
|
|
1195
|
+
const feeMultiplier = new BigNumber(1).minus(totalFeeRate);
|
|
1196
|
+
hopMarginalRate = isCoinAInput
|
|
1197
|
+
? reserveB.div(reserveA).times(feeMultiplier)
|
|
1198
|
+
: reserveA.div(reserveB).times(feeMultiplier);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if ((venue === 'clob' || venue === 'clob+amm') && !hopMarginalRate) {
|
|
1203
|
+
// CLOB-only or CLOB+AMM where pool is missing: use best price level
|
|
1204
|
+
const marginalClobInfo = deriveClobPairAndSide(step.from, step.to, clobFees?.tickerPattern, marketsLookup, coins);
|
|
1205
|
+
const depth = marginalClobInfo && orderbooks && orderbooks[marginalClobInfo.pair];
|
|
1206
|
+
const clobSide = marginalClobInfo?.side;
|
|
1207
|
+
if (depth) {
|
|
1208
|
+
const clobLevels = clobSide === 'buy' ? (depth.asks || []) : (depth.bids || []);
|
|
1209
|
+
if (clobLevels.length > 0) {
|
|
1210
|
+
const bestPriceHuman = new BigNumber(clobLevels[0][0]);
|
|
1211
|
+
if (bestPriceHuman.gt(0)) {
|
|
1212
|
+
const TAKER_FEE_RATE = new BigNumber(clobFees?.takerFeeRate || '0.002');
|
|
1213
|
+
const feeMultiplier = new BigNumber(1).minus(TAKER_FEE_RATE);
|
|
1214
|
+
if (clobSide === 'buy') {
|
|
1215
|
+
// 1 quote-whole → (10^baseDp / bestPrice_whole) base-whole, after fee
|
|
1216
|
+
// bestPrice_whole = bestPriceHuman * 10^quoteDp where quoteDp = inputDp
|
|
1217
|
+
const bestPriceWhole = bestPriceHuman.shiftedBy(currentCoinDp);
|
|
1218
|
+
hopMarginalRate = new BigNumber(10).pow(outputCoinDp).div(bestPriceWhole).times(feeMultiplier);
|
|
1219
|
+
} else {
|
|
1220
|
+
// 1 base-whole → (bestPrice_whole / 10^baseDp) quote-whole, after fee
|
|
1221
|
+
// bestPrice_whole = bestPriceHuman * 10^quoteDp where quoteDp = outputDp
|
|
1222
|
+
const bestPriceWhole = bestPriceHuman.shiftedBy(outputCoinDp);
|
|
1223
|
+
hopMarginalRate = bestPriceWhole.div(new BigNumber(10).pow(currentCoinDp)).times(feeMultiplier);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (hopMarginalRate && hopMarginalRate.gt(0) && hopMarginalRate.isFinite()) {
|
|
1231
|
+
compoundMarginalRate = compoundMarginalRate.times(hopMarginalRate);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// venueHint is sent to the backend as step.venue — must be 'amm' or 'clob'.
|
|
1235
|
+
// For 'clob+amm' (spillover), hint 'clob' so the backend tries CLOB first
|
|
1236
|
+
// and its own spillover logic routes the remainder through AMM.
|
|
1237
|
+
const venueHint = venue === 'clob+amm' ? 'clob' : venue;
|
|
1238
|
+
|
|
1239
|
+
enrichedPath.push({
|
|
1240
|
+
from: step.from,
|
|
1241
|
+
to: step.to,
|
|
1242
|
+
venue: venueHint,
|
|
1243
|
+
venueDisplay: venue, // preserved for UI display (e.g. "CLOB+AMM")
|
|
1244
|
+
output: amountOutBN.shiftedBy(-outputCoinDp).toString(),
|
|
1245
|
+
});
|
|
277
1246
|
|
|
278
1247
|
currentAmount = amountOutBN;
|
|
279
1248
|
currentCoin = outputCoin;
|
|
280
1249
|
if (step !== path[path.length - 1]) {
|
|
281
1250
|
intermediateAmounts.push({
|
|
282
1251
|
ticker: step.to,
|
|
283
|
-
amount: currentAmount.shiftedBy(-
|
|
1252
|
+
amount: currentAmount.shiftedBy(-outputCoinDp).toString(),
|
|
284
1253
|
});
|
|
285
1254
|
}
|
|
286
1255
|
}
|
|
287
1256
|
|
|
288
1257
|
const outputCoin = coins.find((c) => c.ticker === path[path.length - 1].to);
|
|
289
|
-
const outputCoinDp = outputCoin
|
|
1258
|
+
const outputCoinDp = getDp(outputCoin);
|
|
290
1259
|
if (
|
|
291
1260
|
new BigNumber(currentAmount)
|
|
292
1261
|
.shiftedBy(-outputCoinDp)
|
|
@@ -295,23 +1264,52 @@ export const estimatePath = async (pools, path, inputCoin, amountIn, coins) => {
|
|
|
295
1264
|
return null;
|
|
296
1265
|
}
|
|
297
1266
|
|
|
1267
|
+
// End-to-end price impact (audit fix L6, 2026-03-16):
|
|
1268
|
+
// Compare the actual execution rate against the compound marginal (spot) rate.
|
|
1269
|
+
// actualRate = totalOutput / totalInput (in whole units)
|
|
1270
|
+
// marginalRate = compoundMarginalRate (product of per-hop spot rates)
|
|
1271
|
+
// priceImpact = 1 - (actualRate / marginalRate)
|
|
1272
|
+
//
|
|
1273
|
+
// This correctly captures compounding across multi-hop paths, handles mixed
|
|
1274
|
+
// AMM+CLOB venues uniformly, and isolates size-dependent slippage from fees
|
|
1275
|
+
// (fees are present in both the marginal and actual rates, so they cancel out).
|
|
1276
|
+
let priceImpact = 0;
|
|
1277
|
+
const inputWhole = new BigNumber(amountIn).shiftedBy(getDp(inputCoin));
|
|
1278
|
+
if (inputWhole.gt(0) && compoundMarginalRate.gt(0) && compoundMarginalRate.isFinite()) {
|
|
1279
|
+
const actualRate = currentAmount.div(inputWhole);
|
|
1280
|
+
const impactRatio = actualRate.div(compoundMarginalRate);
|
|
1281
|
+
// 1 - impactRatio: positive means user gets less than spot (normal slippage)
|
|
1282
|
+
const impact = new BigNumber(1).minus(impactRatio).toNumber();
|
|
1283
|
+
if (Number.isFinite(impact) && impact >= 0) {
|
|
1284
|
+
priceImpact = impact;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
298
1288
|
return {
|
|
299
1289
|
amountOut: currentAmount,
|
|
300
|
-
priceImpact
|
|
1290
|
+
priceImpact,
|
|
301
1291
|
intermediateAmounts,
|
|
302
1292
|
updatedPools,
|
|
1293
|
+
enrichedPath,
|
|
1294
|
+
...(clobEstimateLowConfidence ? { clobEstimateLowConfidence: true } : {}),
|
|
1295
|
+
...(spilloverEstimateLowConfidence ? { spilloverEstimateLowConfidence: true } : {}),
|
|
303
1296
|
};
|
|
304
1297
|
};
|
|
305
1298
|
|
|
306
1299
|
// Main estimateSwap function
|
|
307
|
-
|
|
1300
|
+
// orderbooks: optional { [pair]: { bids, asks } } for CLOB comparison per hop
|
|
1301
|
+
export const estimateSwap = (
|
|
308
1302
|
inputCoin,
|
|
309
1303
|
outputCoin,
|
|
310
1304
|
amountIn,
|
|
311
1305
|
pools,
|
|
312
1306
|
coins,
|
|
313
1307
|
maxHops = 6,
|
|
314
|
-
algorithm = 'dfs' // Default to DFS
|
|
1308
|
+
algorithm = 'dfs', // Default to DFS
|
|
1309
|
+
orderbooks = null,
|
|
1310
|
+
userOrders = null,
|
|
1311
|
+
clobFees = null,
|
|
1312
|
+
marketsLookup = null,
|
|
315
1313
|
) => {
|
|
316
1314
|
// Validate inputs
|
|
317
1315
|
if (!inputCoin || !outputCoin) {
|
|
@@ -319,8 +1317,12 @@ export const estimateSwap = async (
|
|
|
319
1317
|
}
|
|
320
1318
|
validatePositiveNumber(amountIn, 'amountIn');
|
|
321
1319
|
validatePositiveNumber(maxHops, 'maxHops');
|
|
322
|
-
|
|
323
|
-
|
|
1320
|
+
// Aligned with backend limit: swapCore.mjs rejects paths longer than 10 hops,
|
|
1321
|
+
// and the swap route (api/routes/swap/swap.mjs) validates path.length <= 10.
|
|
1322
|
+
// Previously 14, which allowed the frontend to display routes the backend
|
|
1323
|
+
// would reject on submission. (Audit fix H5, 2026-03-17)
|
|
1324
|
+
if (maxHops < 1 || maxHops > 10) {
|
|
1325
|
+
throw new Error('Max hops must be between 1 and 10');
|
|
324
1326
|
}
|
|
325
1327
|
if (!['dfs', 'bfs'].includes(algorithm)) {
|
|
326
1328
|
throw new Error('Invalid algorithm: must be "dfs" or "bfs"');
|
|
@@ -332,7 +1334,7 @@ export const estimateSwap = async (
|
|
|
332
1334
|
throw new Error(`Coin not found: ${inputCoin.ticker} or ${outputCoin.ticker}`);
|
|
333
1335
|
}
|
|
334
1336
|
|
|
335
|
-
const inputCoinDp = inputCoinData
|
|
1337
|
+
const inputCoinDp = getDp(inputCoinData);
|
|
336
1338
|
const amountInBN = new BigNumber(amountIn);
|
|
337
1339
|
if (amountInBN.decimalPlaces() > inputCoinDp) {
|
|
338
1340
|
throw new Error(
|
|
@@ -347,10 +1349,22 @@ export const estimateSwap = async (
|
|
|
347
1349
|
);
|
|
348
1350
|
}
|
|
349
1351
|
|
|
350
|
-
//
|
|
351
|
-
|
|
1352
|
+
// Validate against per-coin swap minimum input. The backend rejects swaps
|
|
1353
|
+
// below this threshold, so checking here prevents the user from seeing an
|
|
1354
|
+
// estimate that would be rejected on submission. (Audit fix L5, 2026-03-16)
|
|
1355
|
+
if (inputCoinData.swapMinimumInput) {
|
|
1356
|
+
const swapMin = new BigNumber(inputCoinData.swapMinimumInput);
|
|
1357
|
+
if (swapMin.gt(0) && amountInBN.lt(swapMin)) {
|
|
1358
|
+
throw new Error(
|
|
1359
|
+
`Input amount ${amountIn} ${inputCoin.ticker} is less than the swap minimum (${swapMin.toString()})`
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Find all paths using the specified algorithm (include orderbook pairs in graph)
|
|
1365
|
+
const paths = findAllPaths(inputCoinData, outputCoinData, pools, maxHops, algorithm, coins, orderbooks);
|
|
352
1366
|
if (!paths.length) {
|
|
353
|
-
throw new Error('No valid swap paths found
|
|
1367
|
+
throw new Error('No valid swap paths found');
|
|
354
1368
|
}
|
|
355
1369
|
|
|
356
1370
|
// Estimate each path
|
|
@@ -359,7 +1373,7 @@ export const estimateSwap = async (
|
|
|
359
1373
|
let bestResult = null;
|
|
360
1374
|
|
|
361
1375
|
for (const path of paths) {
|
|
362
|
-
const result =
|
|
1376
|
+
const result = estimatePath(pools, path, inputCoinData, amountIn, coins, orderbooks, userOrders, clobFees, marketsLookup);
|
|
363
1377
|
if (result && result.amountOut.gt(maxAmountOut)) {
|
|
364
1378
|
maxAmountOut = result.amountOut;
|
|
365
1379
|
bestPath = path;
|
|
@@ -371,28 +1385,40 @@ export const estimateSwap = async (
|
|
|
371
1385
|
throw new Error('No valid swap path with positive output using RUNES-compliant pools');
|
|
372
1386
|
}
|
|
373
1387
|
|
|
374
|
-
// Calculate USD prices
|
|
375
|
-
|
|
1388
|
+
// Calculate USD prices. For CLOB-only pairs without a corresponding RUNES
|
|
1389
|
+
// AMM pool, price lookups return '0'. Instead of throwing (which blocks the
|
|
1390
|
+
// entire estimation), return null USD values — the swap path is still valid,
|
|
1391
|
+
// just without USD display data. (Audit fix L5, 2026-03-18)
|
|
1392
|
+
const runesPriceUSD = getRunesPriceUSD(pools);
|
|
376
1393
|
const priceAInRunes = getTokenPriceInRunes(inputCoinData, pools);
|
|
377
1394
|
const priceBInRunes = getTokenPriceInRunes(outputCoinData, pools);
|
|
378
|
-
|
|
379
|
-
throw new Error('Pool not initialized or invalid token');
|
|
380
|
-
}
|
|
1395
|
+
const hasUsdPrices = priceAInRunes && priceAInRunes !== '0' && priceBInRunes && priceBInRunes !== '0';
|
|
381
1396
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1397
|
+
let priceAUSD = null;
|
|
1398
|
+
let priceBUSD = null;
|
|
1399
|
+
let inputValueUSD = null;
|
|
1400
|
+
let outputValueUSD = null;
|
|
1401
|
+
let afterSwapPrices = null;
|
|
385
1402
|
|
|
386
|
-
|
|
387
|
-
.
|
|
388
|
-
.times(
|
|
389
|
-
.toString();
|
|
1403
|
+
if (hasUsdPrices) {
|
|
1404
|
+
priceAUSD = new BigNumber(priceAInRunes).times(runesPriceUSD).toString();
|
|
1405
|
+
priceBUSD = new BigNumber(priceBInRunes).times(runesPriceUSD).toString();
|
|
1406
|
+
inputValueUSD = new BigNumber(amountIn).times(priceAUSD).toString();
|
|
1407
|
+
outputValueUSD = maxAmountOut
|
|
1408
|
+
.shiftedBy(-outputCoinData.dp)
|
|
1409
|
+
.times(priceBUSD)
|
|
1410
|
+
.toString();
|
|
390
1411
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
1412
|
+
// After-swap prices: use the already-simulated updated pools from bestResult
|
|
1413
|
+
// (estimatePath already tracks post-swap reserve states, no need to re-simulate)
|
|
1414
|
+
const runesPriceUSDAfter = getRunesPriceUSD(bestResult.updatedPools);
|
|
1415
|
+
const priceAInRunesAfter = new BigNumber(getTokenPriceInRunes(inputCoinData, bestResult.updatedPools));
|
|
1416
|
+
const priceBInRunesAfter = new BigNumber(getTokenPriceInRunes(outputCoinData, bestResult.updatedPools));
|
|
1417
|
+
afterSwapPrices = {
|
|
1418
|
+
[inputCoin.ticker]: priceAInRunesAfter.times(runesPriceUSDAfter).toString(),
|
|
1419
|
+
[outputCoin.ticker]: priceBInRunesAfter.times(runesPriceUSDAfter).toString(),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
396
1422
|
|
|
397
1423
|
return {
|
|
398
1424
|
input: {
|
|
@@ -400,24 +1426,28 @@ export const estimateSwap = async (
|
|
|
400
1426
|
amount: amountIn,
|
|
401
1427
|
priceUSD: priceAUSD,
|
|
402
1428
|
valueUSD: inputValueUSD,
|
|
403
|
-
priceInRunes: priceAInRunes,
|
|
1429
|
+
priceInRunes: priceAInRunes || '0',
|
|
404
1430
|
},
|
|
405
1431
|
output: {
|
|
406
1432
|
token: outputCoin.ticker,
|
|
407
1433
|
amount: maxAmountOut.shiftedBy(-outputCoinData.dp).toString(),
|
|
408
1434
|
priceUSD: priceBUSD,
|
|
409
1435
|
valueUSD: outputValueUSD,
|
|
410
|
-
priceInRunes: priceBInRunes,
|
|
1436
|
+
priceInRunes: priceBInRunes || '0',
|
|
411
1437
|
},
|
|
412
1438
|
slippage: {
|
|
413
1439
|
priceImpact: bestResult.priceImpact * 100,
|
|
414
1440
|
intermediateAmounts: bestResult.intermediateAmounts,
|
|
415
1441
|
},
|
|
416
|
-
afterSwapPrices
|
|
417
|
-
|
|
418
|
-
[outputCoin.ticker]: priceBInRunesAfter.times(runesPriceUSDAfter).toString(),
|
|
419
|
-
},
|
|
420
|
-
path: bestPath,
|
|
1442
|
+
afterSwapPrices,
|
|
1443
|
+
path: bestResult.enrichedPath,
|
|
421
1444
|
algorithm, // Include algorithm in the response
|
|
1445
|
+
// Low-confidence flags: when set, the estimate may diverge from actual
|
|
1446
|
+
// execution. The UI should display a warning (e.g. wider slippage recommended).
|
|
1447
|
+
// - clobEstimateLowConfidence: CLOB used equal-split fallback (no per-order sizes)
|
|
1448
|
+
// - spilloverEstimateLowConfidence: CLOB+AMM split used potentially stale pool reserves
|
|
1449
|
+
// (Audit fix M4, 2026-03-17)
|
|
1450
|
+
...(bestResult.clobEstimateLowConfidence ? { clobEstimateLowConfidence: true } : {}),
|
|
1451
|
+
...(bestResult.spilloverEstimateLowConfidence ? { spilloverEstimateLowConfidence: true } : {}),
|
|
422
1452
|
};
|
|
423
|
-
};
|
|
1453
|
+
};
|