@runesx/api-client 0.0.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runesx/api-client",
3
- "version": "0.0.6",
3
+ "version": "0.2.0",
4
4
  "description": "A Node.js client for interacting with the RunesX platform API and WebSocket",
5
5
  "main": "src/index.mjs",
6
6
  "type": "module",
@@ -22,10 +22,10 @@
22
22
  "@semantic-release/changelog": "^6.0.3",
23
23
  "@semantic-release/git": "^10.0.1",
24
24
  "@semantic-release/npm": "^13.1.4",
25
- "dotenv": "^17.2.4",
25
+ "dotenv": "^17.3.1",
26
26
  "eslint": "^9.11.1",
27
27
  "eslint-plugin-import": "^2.30.0",
28
- "eslint-plugin-n": "^17.23.2",
28
+ "eslint-plugin-n": "^17.24.0",
29
29
  "eslint-plugin-promise": "^7.1.0",
30
30
  "globals": "^17.3.0",
31
31
  "jest": "^30.2.0",
package/src/api.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  // src/api.mjs
2
+ import { randomUUID } from 'crypto';
3
+
2
4
  import axios from 'axios';
3
5
 
4
6
  export function createApi(config) {
@@ -19,32 +21,59 @@ export function createApi(config) {
19
21
  }
20
22
  }
21
23
 
22
- async function postSwap({ amountIn, path, minAmountOut }) {
24
+ async function postSwap({ amountIn, path, minAmountOut, idempotencyKey }) {
23
25
  try {
24
- const response = await api.post('/swap', { amountIn, path, minAmountOut });
26
+ const key = idempotencyKey || randomUUID();
27
+ const response = await api.post('/swap', { amountIn, path, minAmountOut }, {
28
+ headers: { 'X-Idempotency-Key': key },
29
+ });
25
30
  return response.data;
26
31
  } catch (error) {
27
32
  throw new Error(error.response?.data?.error || 'Failed to execute swap');
28
33
  }
29
34
  }
30
35
 
31
- async function depositLiquidity({ coinA, coinB, amountA, amountB }) {
36
+ async function depositLiquidity({ tickerA, tickerB, amountA, amountB, minShares, idempotencyKey }) {
32
37
  try {
33
- const response = await api.post('/liquidity/deposit', { coinA, coinB, amountA, amountB });
38
+ const key = idempotencyKey || randomUUID();
39
+ const headers = { 'X-Idempotency-Key': key };
40
+ const body = { tickerA, tickerB, amountA, amountB };
41
+ if (minShares !== undefined && minShares !== null) {
42
+ body.minShares = minShares;
43
+ }
44
+ const response = await api.post('/liquidity/deposit', body, { headers });
34
45
  return response.data.data;
35
46
  } catch (error) {
36
47
  throw new Error(error.response?.data?.error || 'Failed to deposit liquidity');
37
48
  }
38
49
  }
39
50
 
40
- async function withdrawLiquidity({ coinA, coinB, shares }) {
51
+ async function withdrawLiquidity({ tickerA, tickerB, shares, minAmountA, minAmountB, idempotencyKey }) {
41
52
  try {
42
- const response = await api.post('/liquidity/withdraw', { coinA, coinB, shares });
53
+ const key = idempotencyKey || randomUUID();
54
+ const headers = { 'X-Idempotency-Key': key };
55
+ const body = { tickerA, tickerB, shares };
56
+ if (minAmountA !== undefined && minAmountA !== null) {
57
+ body.minAmountA = minAmountA;
58
+ }
59
+ if (minAmountB !== undefined && minAmountB !== null) {
60
+ body.minAmountB = minAmountB;
61
+ }
62
+ const response = await api.post('/liquidity/withdraw', body, { headers });
43
63
  return response.data.data;
44
64
  } catch (error) {
45
65
  throw new Error(error.response?.data?.error || 'Failed to withdraw liquidity');
46
66
  }
47
67
  }
48
68
 
49
- return { getWallets, postSwap, depositLiquidity, withdrawLiquidity };
50
- }
69
+ async function getPools() {
70
+ try {
71
+ const response = await api.get('/pools');
72
+ return response.data.data;
73
+ } catch (error) {
74
+ throw new Error(error.response?.data?.error || 'Failed to fetch pools');
75
+ }
76
+ }
77
+
78
+ return { getWallets, getPools, postSwap, depositLiquidity, withdrawLiquidity };
79
+ }
package/src/index.mjs CHANGED
@@ -7,7 +7,7 @@ import { getCoins, getCoinByTicker } from './store/coinStore.mjs';
7
7
  import { getWallets as getWalletsStore, getWalletByTicker } from './store/walletStore.mjs';
8
8
  import { getUserShares, getUserShareByPoolId } from './store/userSharesStore.mjs';
9
9
  import { waitForStores } from './waitForStores.mjs';
10
- import { estimateLiquidityFrontend, checkRunesLiquidityFrontend, calculateShareAmounts } from './utils/liquidityUtils.mjs';
10
+ import { estimateLiquidityFrontend, checkRunesLiquidityFrontend, calculateShareAmounts, estimateDepositShares } from './utils/liquidityUtils.mjs';
11
11
  import { estimateSwap } from './utils/swapUtils.mjs';
12
12
  import { createPriceUtils } from './utils/priceUtils.mjs';
13
13
 
@@ -54,6 +54,8 @@ export function createRunesXClient(options = {}) {
54
54
  estimateSwap: (inputCoin, outputCoin, amountIn, maxHops = 6, algorithm = 'dfs') =>
55
55
  estimateSwap(inputCoin, outputCoin, amountIn, getPools(), getCoins(), maxHops, algorithm),
56
56
  estimateLiquidityFrontend,
57
+ estimateDepositShares: ({ pool, amountA, amountB, slippagePercent } = {}) =>
58
+ estimateDepositShares({ pool, amountA, amountB, slippagePercent }),
57
59
  checkRunesLiquidityFrontend: (coinA, coinB) =>
58
60
  checkRunesLiquidityFrontend(coinA, coinB, getPools(), getCoins()),
59
61
  calculateShareAmounts: () => calculateShareAmounts({ userShares: getUserShares(), pools: getPools() }),
@@ -174,6 +174,66 @@ export function estimateLiquidityFrontend({ coinA, coinB, amountA, amountB, pool
174
174
  };
175
175
  }
176
176
 
177
+ /**
178
+ * Estimate the shares that would be minted for a liquidity deposit.
179
+ * Uses the same formula as the backend engine.
180
+ *
181
+ * @param {Object} params
182
+ * @param {Object} params.pool - Pool object from getPools() (must include reserveA, reserveB, totalShares, coinA.dp, coinB.dp)
183
+ * @param {string} params.amountA - Decimal amount of tokenA to deposit
184
+ * @param {string} params.amountB - Decimal amount of tokenB to deposit
185
+ * @param {number} [params.slippagePercent=2] - Slippage tolerance percentage (e.g. 2 for 2%)
186
+ * @returns {{ estimatedShares: string, minShares: string } | null} null if shares cannot be estimated
187
+ */
188
+ export function estimateDepositShares({ pool, amountA, amountB, slippagePercent = 2 }) {
189
+ if (!pool || !amountA || !amountB) {
190
+ return null;
191
+ }
192
+
193
+ const dpA = pool.coinA.dp;
194
+ const dpB = pool.coinB.dp;
195
+ const amountABN = new BigNumber(amountA).decimalPlaces(dpA, BigNumber.ROUND_DOWN);
196
+ const amountBBN = new BigNumber(amountB).decimalPlaces(dpB, BigNumber.ROUND_DOWN);
197
+
198
+ if (!amountABN.isFinite() || !amountBBN.isFinite() || amountABN.lte(0) || amountBBN.lte(0)) {
199
+ return null;
200
+ }
201
+
202
+ const reserveA = new BigNumber(pool.reserveA);
203
+ const reserveB = new BigNumber(pool.reserveB);
204
+ const isNewPool = reserveA.isZero() && reserveB.isZero();
205
+
206
+ let shares;
207
+ if (isNewPool) {
208
+ // New pool: sqrt(amountA * amountB) * 10^9
209
+ shares = amountABN.times(amountBBN).sqrt().shiftedBy(9).integerValue(BigNumber.ROUND_DOWN);
210
+ } else {
211
+ // Existing pool: min((wholeCoinA * totalShares / reserveA), (wholeCoinB * totalShares / reserveB))
212
+ const totalShares = new BigNumber(pool.totalShares);
213
+ if (totalShares.isZero() || reserveA.isZero() || reserveB.isZero()) {
214
+ return null;
215
+ }
216
+
217
+ const wholeCoinA = amountABN.shiftedBy(dpA).integerValue(BigNumber.ROUND_DOWN);
218
+ const wholeCoinB = amountBBN.shiftedBy(dpB).integerValue(BigNumber.ROUND_DOWN);
219
+ const sharesFromA = wholeCoinA.times(totalShares).div(reserveA).integerValue(BigNumber.ROUND_DOWN);
220
+ const sharesFromB = wholeCoinB.times(totalShares).div(reserveB).integerValue(BigNumber.ROUND_DOWN);
221
+ shares = BigNumber.min(sharesFromA, sharesFromB);
222
+ }
223
+
224
+ if (shares.isZero()) {
225
+ return null;
226
+ }
227
+
228
+ const slippageMultiplier = new BigNumber(1).minus(new BigNumber(slippagePercent).div(100));
229
+ const minShares = shares.times(slippageMultiplier).integerValue(BigNumber.ROUND_DOWN).toString();
230
+
231
+ return {
232
+ estimatedShares: shares.toString(),
233
+ minShares,
234
+ };
235
+ }
236
+
177
237
  export function calculateShareAmounts({ userShares, pools }) {
178
238
  return userShares.map(share => {
179
239
  const pool = pools.find(p => p.id === share.poolId);
@@ -10,9 +10,11 @@ export const validatePositiveNumber = (value, fieldName) => {
10
10
  };
11
11
 
12
12
  // Get RUNES price in USD using RUNES/USDC pool
13
- export const getRunesPriceUSD = async (pools) => {
13
+ export const getRunesPriceUSD = (pools) => {
14
14
  try {
15
- const runesUsdcPool = pools.find((p) => (p.coinA.ticker === 'RUNES' && p.coinB.ticker === 'USDC'));
15
+ const runesUsdcPool = pools.find((p) =>
16
+ (p.coinA.ticker === 'RUNES' && p.coinB.ticker === 'USDC') ||
17
+ (p.coinA.ticker === 'USDC' && p.coinB.ticker === 'RUNES'));
16
18
 
17
19
  if (!runesUsdcPool) {
18
20
  console.warn('RUNES/USDC pool not found, using fallback price of $0.01');
@@ -63,8 +65,8 @@ export const getTokenPriceInRunes = (token, pools) => {
63
65
  const reserveB = new BigNumber(pool.reserveB).shiftedBy(-pool.coinB.dp);
64
66
 
65
67
  const priceInRunes = isRunesA
66
- ? reserveA.div(reserveB).toString() // RUNES/TOKEN: price = reserveB (TOKEN) / reserveA (RUNES)
67
- : reserveB.div(reserveA).toString(); // TOKEN/RUNES: price = reserveA (TOKEN) / reserveB (RUNES)
68
+ ? reserveA.div(reserveB).toString() // RUNES is coinA: price = RUNES_reserve / TOKEN_reserve
69
+ : reserveB.div(reserveA).toString(); // RUNES is coinB: price = RUNES_reserve / TOKEN_reserve
68
70
 
69
71
  return priceInRunes;
70
72
  };
@@ -74,6 +76,18 @@ const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6) => {
74
76
  const paths = [];
75
77
  const visited = new Set();
76
78
 
79
+ // Build adjacency map for O(1) lookups (matches BFS approach)
80
+ const poolMap = new Map();
81
+ pools.forEach((pool) => {
82
+ if (!pool.runesCompliant || new BigNumber(pool.reserveA).isZero() || new BigNumber(pool.reserveB).isZero()) {return;}
83
+ const keyA = pool.coinA.ticker;
84
+ 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 });
89
+ });
90
+
77
91
  function dfs(currentCoin, currentPath, hops) {
78
92
  if (hops > maxHops) {return;}
79
93
  if (currentCoin.ticker === endCoin.ticker) {
@@ -81,13 +95,8 @@ const findAllPathsDFS = (startCoin, endCoin, pools, maxHops = 6) => {
81
95
  return;
82
96
  }
83
97
 
84
- for (const pool of pools) {
85
- if (!pool.runesCompliant) {continue;}
86
- const isCoinA = pool.coinA.ticker === currentCoin.ticker;
87
- const isCoinB = pool.coinB.ticker === currentCoin.ticker;
88
- if (!isCoinA && !isCoinB) {continue;}
89
-
90
- const nextCoin = isCoinA ? pool.coinB : pool.coinA;
98
+ const connectedPools = poolMap.get(currentCoin.ticker) || [];
99
+ for (const { pool, nextCoin } of connectedPools) {
91
100
  const poolKey = pool.id;
92
101
 
93
102
  if (visited.has(poolKey)) {continue;}
@@ -175,7 +184,11 @@ export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
175
184
  const totalFeeRate = lpFeeRate.plus(treasuryFeeRate);
176
185
 
177
186
  // Calculate total fee and split proportionally
178
- const totalFeeAmount = amountInBN.times(totalFeeRate).integerValue(BigNumber.ROUND_DOWN);
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
+ }
179
192
  let treasuryFeeAmount = new BigNumber(0);
180
193
  let lpFeeAmount = new BigNumber(0);
181
194
  if (!totalFeeRate.eq(0)) {
@@ -197,7 +210,7 @@ export const simulateSwap = (pool, inputCoin, amountInBN, isCoinAInput) => {
197
210
  }
198
211
  amountOutBN = amountOutBN.integerValue(BigNumber.ROUND_DOWN);
199
212
 
200
- if (amountOutBN.isNaN() || amountOutBN.lt(1)) {return null;}
213
+ if (amountOutBN.isNaN() || !amountOutBN.isFinite() || amountOutBN.lt(1)) {return null;}
201
214
 
202
215
  // Simulate reserve updates (mimicking backend)
203
216
  const updatedPool = { ...pool };
@@ -362,7 +375,7 @@ export const estimateSwap = async (
362
375
  const runesPriceUSD = await getRunesPriceUSD(pools);
363
376
  const priceAInRunes = getTokenPriceInRunes(inputCoinData, pools);
364
377
  const priceBInRunes = getTokenPriceInRunes(outputCoinData, pools);
365
- if (!priceAInRunes || !priceBInRunes) {
378
+ if (!priceAInRunes || priceAInRunes === '0' || !priceBInRunes || priceBInRunes === '0') {
366
379
  throw new Error('Pool not initialized or invalid token');
367
380
  }
368
381
 
@@ -375,48 +388,11 @@ export const estimateSwap = async (
375
388
  .times(priceBUSD)
376
389
  .toString();
377
390
 
378
- // Simulate after-swap prices
379
- let priceAInRunesAfter = new BigNumber(priceAInRunes);
380
- let priceBInRunesAfter = new BigNumber(priceBInRunes);
381
- let currentAmount = new BigNumber(amountIn).shiftedBy(inputCoinData.dp || 8);
382
- let currentCoin = inputCoinData;
383
- let updatedPools = [...pools]; // Clone pools for after-swap price simulation
384
-
385
- for (const step of bestPath) {
386
- const poolIndex = updatedPools.findIndex(
387
- (p) =>
388
- (p.coinA.ticker === step.from && p.coinB.ticker === step.to) ||
389
- (p.coinB.ticker === step.from && p.coinA.ticker === step.to)
390
- );
391
- const pool = updatedPools[poolIndex];
392
- const isCoinAInput = pool.coinA.ticker === step.from;
393
- const nextCoin = coins.find((c) => c.ticker === step.to);
394
- const swapResult = simulateSwap(pool, currentCoin, currentAmount, isCoinAInput);
395
-
396
- const reserveA = new BigNumber(pool.reserveA);
397
- const reserveB = new BigNumber(pool.reserveB);
398
- if (isCoinAInput) {
399
- const newRunesReserve = reserveA.plus(currentAmount);
400
- const newTokenReserve = reserveB.minus(swapResult.amountOut);
401
- if (step.from === 'RUNES') {
402
- priceAInRunesAfter = newRunesReserve.div(newTokenReserve);
403
- } else if (step.to === 'RUNES') {
404
- priceBInRunesAfter = newRunesReserve.div(newTokenReserve);
405
- }
406
- } else {
407
- const newTokenReserve = reserveB.plus(currentAmount);
408
- const newRunesReserve = reserveA.minus(swapResult.amountOut);
409
- if (step.from === 'RUNES') {
410
- priceAInRunesAfter = newRunesReserve.div(newTokenReserve);
411
- } else if (step.to === 'RUNES') {
412
- priceBInRunesAfter = newRunesReserve.div(newTokenReserve);
413
- }
414
- }
415
-
416
- currentAmount = swapResult.amountOut;
417
- currentCoin = nextCoin;
418
- updatedPools[poolIndex] = swapResult.updatedPool; // Update pool for next iteration
419
- }
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));
420
396
 
421
397
  return {
422
398
  input: {
@@ -438,8 +414,8 @@ export const estimateSwap = async (
438
414
  intermediateAmounts: bestResult.intermediateAmounts,
439
415
  },
440
416
  afterSwapPrices: {
441
- [inputCoin.ticker]: priceAInRunesAfter.times(runesPriceUSD).toString(),
442
- [outputCoin.ticker]: priceBInRunesAfter.times(runesPriceUSD).toString(),
417
+ [inputCoin.ticker]: priceAInRunesAfter.times(runesPriceUSDAfter).toString(),
418
+ [outputCoin.ticker]: priceBInRunesAfter.times(runesPriceUSDAfter).toString(),
443
419
  },
444
420
  path: bestPath,
445
421
  algorithm, // Include algorithm in the response