@runesx/api-client 0.4.0 → 0.5.1

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.
@@ -1,5 +1,21 @@
1
1
  // src/utils/swapUtils.mjs
2
- import { BigNumber } from 'bignumber.js';
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.find((p) =>
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, using fallback price of $0.01');
21
- return '0.01';
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, using fallback price of $0.01');
29
- return '0.01';
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, using fallback price of $0.01');
41
- return '0.01';
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.01';
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.find(
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
- // DFS-based pathfinding
75
- const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6) => {
76
- const paths = [];
77
- const visited = new Set();
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 adjacency map for O(1) lookups (matches BFS approach)
80
- const poolMap = new Map();
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 (!poolMap.has(keyA)) {poolMap.set(keyA, []);}
86
- if (!poolMap.has(keyB)) {poolMap.set(keyB, []);}
87
- poolMap.get(keyA).push({ pool, nextCoin: pool.coinB });
88
- poolMap.get(keyB).push({ pool, nextCoin: pool.coinA });
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 connectedPools = poolMap.get(currentCoin.ticker) || [];
99
- for (const { pool, nextCoin } of connectedPools) {
100
- const poolKey = pool.id;
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(poolKey);
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
- const findAllPathsBFS = (startCoin, endCoin, pools, maxHops = 6, maxPaths = 20) => {
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 visited = new Set();
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 connectedPools = poolMap.get(coin.ticker) || [];
148
- for (const { pool, nextCoin } of connectedPools) {
149
- const poolKey = `${coin.ticker}-${pool.id}`;
150
- if (visited.has(poolKey)) {continue;}
151
- visited.add(poolKey);
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
- export const findAllPaths = (startCoin, endCoin, pools, maxHops = 6, algorithm = 'dfs') => {
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); // Limit to 20 paths for BFS
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
- let totalFeeAmount = amountInBN.times(totalFeeRate).integerValue(BigNumber.ROUND_DOWN);
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 = async (pools, path, inputCoin, amountIn, coins) => {
235
- let currentAmount = new BigNumber(amountIn).shiftedBy(inputCoin.dp || 8);
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
- for (const step of path) {
242
- const poolIndex = updatedPools.findIndex(
243
- (p) =>
244
- (p.coinA.ticker === step.from && p.coinB.ticker === step.to) ||
245
- (p.coinB.ticker === step.from && p.coinA.ticker === step.to)
246
- );
247
- if (poolIndex === -1 || !updatedPools[poolIndex].runesCompliant) {return null;}
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
- const pool = updatedPools[poolIndex];
250
- const isCoinAInput = pool.coinA.ticker === step.from;
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.dp || 8;
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
- const swapResult = simulateSwap(pool, currentCoin, currentAmount, isCoinAInput);
261
- if (!swapResult || swapResult.amountOut.lte(0)) {return null;}
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
- const { amountOut: amountOutBN, updatedPool } = swapResult;
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
- // Update the pool in the cloned pools array
266
- updatedPools[poolIndex] = updatedPool;
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
- const reserveA = new BigNumber(pool.reserveA);
269
- const reserveB = new BigNumber(pool.reserveB);
270
- const spotPrice = isCoinAInput ? reserveB.div(reserveA) : reserveA.div(reserveB);
271
- const totalFeeRate = new BigNumber(pool.lpFeeRate).plus(pool.treasuryFeeRate).div(100);
272
- const effectivePrice = amountOutBN.div(
273
- currentAmount.times(new BigNumber(1).minus(totalFeeRate))
274
- );
275
- const stepPriceImpact = spotPrice.minus(effectivePrice).div(spotPrice).abs().toNumber();
276
- priceImpact += stepPriceImpact;
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(-(outputCoin.dp || 8)).toString(),
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.dp || 8;
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: priceImpact / path.length,
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
- export const estimateSwap = async (
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
- if (maxHops < 1 || maxHops > 14) {
323
- throw new Error('Max hops must be between 1 and 14');
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.dp || 8;
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
- // Find all paths using the specified algorithm
351
- const paths = findAllPaths(inputCoinData, outputCoinData, pools, maxHops, algorithm);
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 with RUNES-compliant pools');
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 = await estimatePath(pools, path, inputCoinData, amountIn, coins);
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
- const runesPriceUSD = await getRunesPriceUSD(pools);
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
- if (!priceAInRunes || priceAInRunes === '0' || !priceBInRunes || priceBInRunes === '0') {
379
- throw new Error('Pool not initialized or invalid token');
380
- }
1395
+ const hasUsdPrices = priceAInRunes && priceAInRunes !== '0' && priceBInRunes && priceBInRunes !== '0';
381
1396
 
382
- const priceAUSD = new BigNumber(priceAInRunes).times(runesPriceUSD).toString();
383
- const priceBUSD = new BigNumber(priceBInRunes).times(runesPriceUSD).toString();
384
- const inputValueUSD = new BigNumber(amountIn).times(priceAUSD).toString();
1397
+ let priceAUSD = null;
1398
+ let priceBUSD = null;
1399
+ let inputValueUSD = null;
1400
+ let outputValueUSD = null;
1401
+ let afterSwapPrices = null;
385
1402
 
386
- const outputValueUSD = maxAmountOut
387
- .shiftedBy(-outputCoinData.dp)
388
- .times(priceBUSD)
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
- // After-swap prices: use the already-simulated updated pools from bestResult
392
- // (estimatePath already tracks post-swap reserve states, no need to re-simulate)
393
- const runesPriceUSDAfter = getRunesPriceUSD(bestResult.updatedPools);
394
- const priceAInRunesAfter = new BigNumber(getTokenPriceInRunes(inputCoinData, bestResult.updatedPools));
395
- const priceBInRunesAfter = new BigNumber(getTokenPriceInRunes(outputCoinData, bestResult.updatedPools));
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
- [inputCoin.ticker]: priceAInRunesAfter.times(runesPriceUSDAfter).toString(),
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
+ };