@silentswap/react 0.0.86 → 0.0.87

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.
@@ -6,6 +6,10 @@ import { usePlatformHealthContext } from '../hooks/usePlatformHealth.js';
6
6
  import { usePricesContext } from './PricesContext.js';
7
7
  import { getAssetByCaip19, CALCULATION_DIRECTION_INPUT_TO_OUTPUT, CALCULATION_DIRECTION_OUTPUT_TO_INPUT, } from '@silentswap/sdk';
8
8
  const XT_DELAY_DEBOUNCE_QUOTE = 550;
9
+ // After a reverse (output-to-input) calculation completes, ignore re-trigger attempts
10
+ // for this duration. Bridge quotes aren't perfectly deterministic, so the calculated
11
+ // input oscillates slightly each cycle — the cooldown prevents infinite loops.
12
+ const REVERSE_CALC_SETTLE_MS = 2000;
9
13
  const SwapFormEstimatesContext = createContext(undefined);
10
14
  export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, serviceFeeRate: serviceFeeRateOverride, }) {
11
15
  const setSplits = useSwap((state) => state.setSplits);
@@ -24,6 +28,7 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
24
28
  const programmaticInputChangeRef = useRef(false);
25
29
  const shouldFetchAfterInputRecalcRef = useRef(false);
26
30
  const isFetchingEstimatesRef = useRef(false);
31
+ const reverseCalcTimestampRef = useRef(0);
27
32
  const [calculationDirection, setCalculationDirection] = React.useState(CALCULATION_DIRECTION_INPUT_TO_OUTPUT);
28
33
  const calculationDirectionRef = useRef(CALCULATION_DIRECTION_INPUT_TO_OUTPUT);
29
34
  // Refs for destination amount calculation (from useDestinationAmountCalculation)
@@ -37,6 +42,14 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
37
42
  if (isDraggingSlider) {
38
43
  return;
39
44
  }
45
+ console.log('[SwapFormEstimates] scheduleFetchEstimates called', {
46
+ immediate,
47
+ direction,
48
+ effectiveDirection: direction || calculationDirectionRef.current || CALCULATION_DIRECTION_INPUT_TO_OUTPUT,
49
+ isFetching: isFetchingEstimatesRef.current,
50
+ cooldownMs: Date.now() - reverseCalcTimestampRef.current,
51
+ caller: new Error().stack?.split('\n')[2]?.trim(),
52
+ });
40
53
  if (debounceTimerRef.current) {
41
54
  clearTimeout(debounceTimerRef.current);
42
55
  debounceTimerRef.current = null;
@@ -47,6 +60,12 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
47
60
  calculationDirectionRef.current = effectiveDirection;
48
61
  setCalculationDirection(effectiveDirection);
49
62
  }
63
+ // Set the cooldown timestamp at the START of a reverse calc (not just in .finally())
64
+ // so that effects firing DURING the fetch (e.g. ref-change effect triggered by
65
+ // SilentSwapAppProvider re-renders) are already within the cooldown window.
66
+ if (effectiveDirection === CALCULATION_DIRECTION_OUTPUT_TO_INPUT) {
67
+ reverseCalcTimestampRef.current = Date.now();
68
+ }
50
69
  isFetchingEstimatesRef.current = true;
51
70
  const fetchPromise = immediate
52
71
  ? fetchEstimatesRef.current(effectiveDirection)
@@ -67,6 +86,13 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
67
86
  console.error('Failed to fetch estimates:', err);
68
87
  })
69
88
  .finally(() => {
89
+ // When a reverse (output-to-input) calculation completes, it updates inputAmount
90
+ // programmatically. Record the timestamp so ALL re-trigger paths (initial-fetch
91
+ // effect AND ref-change effect) will skip during the cooldown window. This prevents
92
+ // infinite loops caused by non-deterministic bridge quotes oscillating slightly.
93
+ if (effectiveDirection === CALCULATION_DIRECTION_OUTPUT_TO_INPUT) {
94
+ reverseCalcTimestampRef.current = Date.now();
95
+ }
70
96
  isFetchingEstimatesRef.current = false;
71
97
  });
72
98
  }, [isDraggingSlider]);
@@ -77,8 +103,25 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
77
103
  React.useEffect(() => {
78
104
  const prev = fetchEstimatesRef.current;
79
105
  fetchEstimatesRef.current = fetchEstimates;
80
- // When fetchEstimates changes and we had already fetched, re-trigger
106
+ // When fetchEstimates changes and we had already fetched, re-trigger
107
+ // but NOT if we're currently fetching (avoids scheduling a duplicate) or
108
+ // if a reverse calc is in progress / recently completed (SilentSwapAppProvider
109
+ // re-renders frequently during fetches, giving fetchEstimates a new identity).
81
110
  if (prev !== fetchEstimates && lastFetchedKeyRef.current) {
111
+ const cooldownMs = Date.now() - reverseCalcTimestampRef.current;
112
+ if (isFetchingEstimatesRef.current ||
113
+ cooldownMs < REVERSE_CALC_SETTLE_MS) {
114
+ console.log('[SwapFormEstimates] ref-change effect SKIPPED (loop guard)', {
115
+ isFetching: isFetchingEstimatesRef.current,
116
+ cooldownMs,
117
+ direction: calculationDirectionRef.current,
118
+ });
119
+ return;
120
+ }
121
+ console.log('[SwapFormEstimates] ref-change effect TRIGGERING fetch', {
122
+ cooldownMs,
123
+ direction: calculationDirectionRef.current,
124
+ });
82
125
  lastFetchedKeyRef.current = null;
83
126
  scheduleFetchEstimates(true);
84
127
  }
@@ -220,10 +263,12 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
220
263
  }, [tokenIn, setSplits, setInputAmount, getPrice, getServiceFeeRate]);
221
264
  // Handle slider change (splits changed)
222
265
  const handleSliderChange = useCallback(() => {
266
+ reverseCalcTimestampRef.current = 0;
223
267
  scheduleFetchEstimates();
224
268
  }, [scheduleFetchEstimates]);
225
269
  // Handle destination amount change (user manually edited)
226
270
  const handleDestinationAmountChange = useCallback(() => {
271
+ reverseCalcTimestampRef.current = 0;
227
272
  // Mark that this is a manual destination change
228
273
  // This prevents performDestinationAmountCalculation from overwriting user's changes
229
274
  isManualDestinationChangeRef.current = true;
@@ -240,6 +285,7 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
240
285
  }, [recalculateSplitsFromAmounts, scheduleFetchEstimates]);
241
286
  // Handle input amount change (user manually edited)
242
287
  const handleInputAmountChange = useCallback(() => {
288
+ reverseCalcTimestampRef.current = 0;
243
289
  // Mark as user-initiated change (not programmatic)
244
290
  programmaticInputChangeRef.current = false;
245
291
  shouldFetchAfterInputRecalcRef.current = false;
@@ -345,6 +391,14 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
345
391
  if (programmaticInputChangeRef.current) {
346
392
  return;
347
393
  }
394
+ // Skip recalculation during the reverse-calc cooldown window.
395
+ // A reverse calc just set inputAmount programmatically — recalculating
396
+ // destination amounts from this input would overwrite the user's desired
397
+ // output values and could feed back into another reverse cycle.
398
+ if (Date.now() - reverseCalcTimestampRef.current < REVERSE_CALC_SETTLE_MS) {
399
+ console.log('[SwapFormEstimates] dest-calc effect SKIPPED (reverse calc cooldown)');
400
+ return;
401
+ }
348
402
  const splitsKey = splits.join(',');
349
403
  const hasSplitsChanged = prevSplitsRef.current !== splitsKey;
350
404
  const hasInputAmountChanged = prevInputAmountForCalcRef.current !== inputAmount;
@@ -414,14 +468,28 @@ export function SwapFormEstimatesProvider({ children, tokenIn, fetchEstimates, s
414
468
  useEffect(() => {
415
469
  // Skip if we're already fetching to prevent loops
416
470
  if (isFetchingEstimatesRef.current) {
471
+ console.log('[SwapFormEstimates] initial-fetch effect SKIPPED (already fetching)', { estimateKey });
417
472
  // Update lastFetchedKeyRef to the new key to prevent re-triggering when fetch completes
418
473
  if (estimateKey) {
419
474
  lastFetchedKeyRef.current = estimateKey;
420
475
  }
421
476
  return;
422
477
  }
478
+ // After a reverse calculation (output-to-input) completes, it programmatically
479
+ // updates inputAmount which changes estimateKey. Skip re-fetching during the
480
+ // cooldown window to avoid an infinite loop (bridge quotes aren't perfectly
481
+ // deterministic, so the calculated input oscillates slightly each cycle).
482
+ const cooldownMs = Date.now() - reverseCalcTimestampRef.current;
483
+ if (cooldownMs < REVERSE_CALC_SETTLE_MS) {
484
+ console.log('[SwapFormEstimates] initial-fetch effect SKIPPED (reverse calc cooldown)', { cooldownMs, estimateKey });
485
+ if (estimateKey) {
486
+ lastFetchedKeyRef.current = estimateKey;
487
+ }
488
+ return;
489
+ }
423
490
  // Only fetch if we have a valid estimate key and it's different from the last one we fetched
424
491
  if (estimateKey && estimateKey !== lastFetchedKeyRef.current) {
492
+ console.log('[SwapFormEstimates] initial-fetch effect TRIGGERING fetch', { estimateKey, lastKey: lastFetchedKeyRef.current });
425
493
  // Mark this key as fetched to prevent duplicate calls
426
494
  lastFetchedKeyRef.current = estimateKey;
427
495
  // Use the debounced fetch function to avoid rapid duplicate calls
@@ -280,10 +280,13 @@ export function useSilentQuote({ client, address, evmAddress, solAddress, wallet
280
280
  if (sourceAssetMatch) {
281
281
  const sourceChainId = parseInt(sourceAssetMatch[1]);
282
282
  const tokenAddress = sourceAssetMatch[2];
283
- await approveTokenSpending(sourceChainId, tokenAddress, effectiveAllowanceTarget, sourceAmountInUnits, evmSignerAddress);
284
- // Delay after approve so RPC/nodes confirm approval before next tx (avoids MetaMask RPC errors)
285
- setCurrentStep('Waiting for network...');
286
- await new Promise((resolve) => setTimeout(resolve, APPROVE_POST_DELAY_MS));
283
+ const approvalHash = await approveTokenSpending(sourceChainId, tokenAddress, effectiveAllowanceTarget, sourceAmountInUnits, evmSignerAddress);
284
+ // Only delay if an approval tx was actually sent (null means allowance was already sufficient)
285
+ if (approvalHash) {
286
+ // Delay after approve so RPC/nodes confirm approval before next tx (avoids MetaMask RPC errors)
287
+ setCurrentStep('Waiting for network...');
288
+ await new Promise((resolve) => setTimeout(resolve, APPROVE_POST_DELAY_MS));
289
+ }
287
290
  }
288
291
  }
289
292
  // Handle EVM bridge swaps
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@silentswap/react",
3
3
  "type": "module",
4
- "version": "0.0.86",
4
+ "version": "0.0.87",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -24,8 +24,8 @@
24
24
  "dependencies": {
25
25
  "@bigmi/core": "^0.6.5",
26
26
  "@ensdomains/ensjs": "^4.2.0",
27
- "@silentswap/sdk": "0.0.86",
28
- "@silentswap/ui-kit": "0.0.86",
27
+ "@silentswap/sdk": "0.0.87",
28
+ "@silentswap/ui-kit": "0.0.87",
29
29
  "@solana/codecs-strings": "^5.1.0",
30
30
  "@solana/kit": "^5.1.0",
31
31
  "@solana/rpc": "^5.1.0",