@nonnux/world-swap 0.1.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/LICENSE +21 -0
- package/README.md +165 -0
- package/package.json +49 -0
- package/src/client.js +851 -0
- package/src/defaults.js +187 -0
- package/src/server.js +458 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// @nonnux/world-swap
|
|
4
|
+
// Drop-in swap card for World App mini apps. Tailwind-styled.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// import { SwapCard } from '@nonnux/world-swap';
|
|
8
|
+
// <SwapCard walletUser={user} Button={Button} />
|
|
9
|
+
//
|
|
10
|
+
// All config is optional and falls back to the nonnux / World Chain defaults.
|
|
11
|
+
// Requires a quote API route (see @nonnux/world-swap/server) mounted at
|
|
12
|
+
// `${apiBasePath}/quote` (default: /api/swap/quote).
|
|
13
|
+
|
|
14
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
15
|
+
import { MiniKit } from '@worldcoin/minikit-js';
|
|
16
|
+
import { ethers } from 'ethers';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TOKENS,
|
|
20
|
+
DEFAULT_CONTRACTS,
|
|
21
|
+
DEFAULT_RPC_URL,
|
|
22
|
+
DEFAULT_EXPLORER_URL,
|
|
23
|
+
} from './defaults.js';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// ABIs
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
const SWAP_ABI_DIRECT = [
|
|
29
|
+
{
|
|
30
|
+
inputs: [
|
|
31
|
+
{ internalType: 'address', name: 'tokenIn', type: 'address' },
|
|
32
|
+
{ internalType: 'address', name: 'tokenOut', type: 'address' },
|
|
33
|
+
{ internalType: 'uint256', name: 'amountIn', type: 'uint256' },
|
|
34
|
+
{ internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
|
|
35
|
+
],
|
|
36
|
+
name: 'executeSwap',
|
|
37
|
+
outputs: [],
|
|
38
|
+
stateMutability: 'nonpayable',
|
|
39
|
+
type: 'function',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const SWAP_ABI_V3 = [
|
|
44
|
+
{
|
|
45
|
+
inputs: [
|
|
46
|
+
{ internalType: 'address', name: 'tokenIn', type: 'address' },
|
|
47
|
+
{ internalType: 'address', name: 'tokenOut', type: 'address' },
|
|
48
|
+
{ internalType: 'uint256', name: 'amountIn', type: 'uint256' },
|
|
49
|
+
{ internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
|
|
50
|
+
{ internalType: 'uint24', name: 'poolFee', type: 'uint24' },
|
|
51
|
+
],
|
|
52
|
+
name: 'executeSwap',
|
|
53
|
+
outputs: [{ internalType: 'uint256', name: 'amountOut', type: 'uint256' }],
|
|
54
|
+
stateMutability: 'nonpayable',
|
|
55
|
+
type: 'function',
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const SWAP_ABI_MULTIHOP = [
|
|
60
|
+
{
|
|
61
|
+
inputs: [
|
|
62
|
+
{ internalType: 'address[]', name: 'path', type: 'address[]' },
|
|
63
|
+
{ internalType: 'uint256', name: 'amountIn', type: 'uint256' },
|
|
64
|
+
{ internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
|
|
65
|
+
],
|
|
66
|
+
name: 'executeSwapMultiHop',
|
|
67
|
+
outputs: [],
|
|
68
|
+
stateMutability: 'nonpayable',
|
|
69
|
+
type: 'function',
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const SWAP_ABI_ROUTER = [
|
|
74
|
+
{
|
|
75
|
+
inputs: [
|
|
76
|
+
{ internalType: 'address', name: 'tokenIn', type: 'address' },
|
|
77
|
+
{ internalType: 'address', name: 'tokenOut', type: 'address' },
|
|
78
|
+
{ internalType: 'uint256', name: 'amountIn', type: 'uint256' },
|
|
79
|
+
{ internalType: 'uint256', name: 'minAmountOut', type: 'uint256' },
|
|
80
|
+
{ internalType: 'uint24', name: 'v3FeeTierOverride', type: 'uint24' },
|
|
81
|
+
],
|
|
82
|
+
name: 'swap',
|
|
83
|
+
outputs: [{ internalType: 'uint256', name: 'amountOut', type: 'uint256' }],
|
|
84
|
+
stateMutability: 'nonpayable',
|
|
85
|
+
type: 'function',
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const TRANSFER_ABI = [
|
|
90
|
+
{
|
|
91
|
+
inputs: [
|
|
92
|
+
{ internalType: 'address', name: 'to', type: 'address' },
|
|
93
|
+
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
|
|
94
|
+
],
|
|
95
|
+
name: 'transfer',
|
|
96
|
+
outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
|
|
97
|
+
stateMutability: 'nonpayable',
|
|
98
|
+
type: 'function',
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)'];
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Storage helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const loadFromStorage = (key, defaultValue) => {
|
|
108
|
+
if (typeof window === 'undefined') return defaultValue;
|
|
109
|
+
try {
|
|
110
|
+
const stored = localStorage.getItem(key);
|
|
111
|
+
if (stored === null) return defaultValue;
|
|
112
|
+
return JSON.parse(stored);
|
|
113
|
+
} catch {
|
|
114
|
+
return defaultValue;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const saveToStorage = (key, value) => {
|
|
119
|
+
if (typeof window === 'undefined') return;
|
|
120
|
+
try {
|
|
121
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore storage errors */
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Balance formatting
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
const formatBalance = (balance) => {
|
|
131
|
+
const num = parseFloat(balance);
|
|
132
|
+
if (isNaN(num) || num === 0) return '0';
|
|
133
|
+
|
|
134
|
+
if (num >= 1000000) {
|
|
135
|
+
const val = num / 1000000;
|
|
136
|
+
if (val >= 100) return val.toFixed(0) + 'M';
|
|
137
|
+
if (val >= 10) return val.toFixed(1) + 'M';
|
|
138
|
+
return val.toFixed(2) + 'M';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (num >= 1000) {
|
|
142
|
+
const val = num / 1000;
|
|
143
|
+
if (val >= 100) return val.toFixed(0) + 'k';
|
|
144
|
+
if (val >= 10) return val.toFixed(1) + 'k';
|
|
145
|
+
return val.toFixed(2) + 'k';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (num >= 100) return num.toFixed(0);
|
|
149
|
+
if (num >= 10) return num.toFixed(1);
|
|
150
|
+
if (num >= 1) return num.toFixed(2);
|
|
151
|
+
|
|
152
|
+
const log = Math.floor(Math.log10(Math.abs(num)));
|
|
153
|
+
const decimals = Math.min(-log + 1, 18);
|
|
154
|
+
return num.toFixed(decimals);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Inline icons (no external icon dependency)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
const ShuffleIcon = ({ className }) => (
|
|
161
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
162
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
|
|
163
|
+
</svg>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const TokenIcon = ({ token, size = 24, ImageComponent }) => {
|
|
167
|
+
const [imgError, setImgError] = useState(false);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
setImgError(false);
|
|
171
|
+
}, [token.symbol]);
|
|
172
|
+
|
|
173
|
+
if (imgError || !token.icon) {
|
|
174
|
+
return <span style={{ fontSize: size * 0.9 }}>{token.emoji || '🪙'}</span>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const Img = ImageComponent || 'img';
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Img
|
|
181
|
+
key={token.symbol}
|
|
182
|
+
src={token.icon}
|
|
183
|
+
alt={token.symbol}
|
|
184
|
+
width={size}
|
|
185
|
+
height={size}
|
|
186
|
+
className="rounded-full object-cover"
|
|
187
|
+
onError={() => setImgError(true)}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Main component
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
/**
|
|
196
|
+
* @param {Object} props
|
|
197
|
+
* @param {Object} props.walletUser Connected user ({ address }). Null = "connect wallet" state.
|
|
198
|
+
* @param {Function} props.Button Button component to render the swap CTA.
|
|
199
|
+
* @param {Array} [props.tokens] Token list (defaults to nonnux ecosystem).
|
|
200
|
+
* @param {Object} [props.contracts] Contract address overrides (merged with defaults).
|
|
201
|
+
* @param {string} [props.rpcUrl] JSON-RPC URL for balance reads.
|
|
202
|
+
* @param {string} [props.apiBasePath] Base path of the quote API (default /api/swap).
|
|
203
|
+
* @param {string} [props.explorerUrl] Block explorer base URL.
|
|
204
|
+
* @param {*} [props.ImageComponent] Image component (e.g. next/image). Defaults to <img>.
|
|
205
|
+
* @param {string} [props.storagePrefix] localStorage key prefix (avoid cross-app collisions).
|
|
206
|
+
* @param {string} [props.title] Header title (default "Swap").
|
|
207
|
+
*/
|
|
208
|
+
export function SwapCard({
|
|
209
|
+
walletUser,
|
|
210
|
+
Button,
|
|
211
|
+
tokens = DEFAULT_TOKENS,
|
|
212
|
+
contracts: contractsProp,
|
|
213
|
+
rpcUrl = DEFAULT_RPC_URL,
|
|
214
|
+
apiBasePath = '/api/swap',
|
|
215
|
+
explorerUrl = DEFAULT_EXPLORER_URL,
|
|
216
|
+
ImageComponent,
|
|
217
|
+
storagePrefix = 'world_swap',
|
|
218
|
+
title = 'Swap',
|
|
219
|
+
}) {
|
|
220
|
+
const contracts = { ...DEFAULT_CONTRACTS, ...(contractsProp || {}) };
|
|
221
|
+
const SUPPORTED_TOKENS = tokens;
|
|
222
|
+
|
|
223
|
+
const STORAGE_KEYS = {
|
|
224
|
+
SLIPPAGE: `${storagePrefix}_slippage`,
|
|
225
|
+
TOKEN_IN: `${storagePrefix}_token_in`,
|
|
226
|
+
TOKEN_OUT: `${storagePrefix}_token_out`,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const [tokenInIndex, setTokenInIndex] = useState(0);
|
|
230
|
+
const [tokenOutIndex, setTokenOutIndex] = useState(1);
|
|
231
|
+
const [slippage, setSlippage] = useState(5);
|
|
232
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
233
|
+
|
|
234
|
+
const [showTokenInDropdown, setShowTokenInDropdown] = useState(false);
|
|
235
|
+
const [showTokenOutDropdown, setShowTokenOutDropdown] = useState(false);
|
|
236
|
+
const [amountIn, setAmountIn] = useState('');
|
|
237
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
238
|
+
const [isLoadingQuote, setIsLoadingQuote] = useState(false);
|
|
239
|
+
const [isLoadingBalance, setIsLoadingBalance] = useState(false);
|
|
240
|
+
const [swapResult, setSwapResult] = useState(null);
|
|
241
|
+
const [quote, setQuote] = useState(null);
|
|
242
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
243
|
+
const [tokenInBalance, setTokenInBalance] = useState('0');
|
|
244
|
+
|
|
245
|
+
const tokenIn = SUPPORTED_TOKENS[tokenInIndex];
|
|
246
|
+
const tokenOut = SUPPORTED_TOKENS[tokenOutIndex];
|
|
247
|
+
|
|
248
|
+
const tokenInVersion = tokenIn.poolVersion || 'v2';
|
|
249
|
+
const tokenOutVersion = tokenOut.poolVersion || 'v2';
|
|
250
|
+
|
|
251
|
+
// Special case: tokens that share a direct V3 pool with WLD.
|
|
252
|
+
const isDirectV3WithWLD =
|
|
253
|
+
(tokenIn.symbol === 'SUSHI' && tokenOut.symbol === 'WLD') ||
|
|
254
|
+
(tokenIn.symbol === 'WLD' && tokenOut.symbol === 'SUSHI');
|
|
255
|
+
|
|
256
|
+
// Pick the contract + mode for this pair.
|
|
257
|
+
let SWAP_CONTRACT_ADDRESS;
|
|
258
|
+
let swapMode; // 'v2' | 'v3' | 'router'
|
|
259
|
+
|
|
260
|
+
if (isDirectV3WithWLD) {
|
|
261
|
+
SWAP_CONTRACT_ADDRESS = contracts.swapV3;
|
|
262
|
+
swapMode = 'v3';
|
|
263
|
+
} else if (tokenInVersion === 'v3' || tokenOutVersion === 'v3') {
|
|
264
|
+
if (contracts.swapRouter && contracts.swapRouter !== '0x0000000000000000000000000000000000000000') {
|
|
265
|
+
SWAP_CONTRACT_ADDRESS = contracts.swapRouter;
|
|
266
|
+
swapMode = 'router';
|
|
267
|
+
} else {
|
|
268
|
+
SWAP_CONTRACT_ADDRESS = contracts.swapV3;
|
|
269
|
+
swapMode = 'v3';
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
SWAP_CONTRACT_ADDRESS = contracts.swapV2;
|
|
273
|
+
swapMode = 'v2';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
SWAP_CONTRACT_ADDRESS = ethers.getAddress(SWAP_CONTRACT_ADDRESS);
|
|
277
|
+
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
// Load persisted prefs
|
|
280
|
+
// -------------------------------------------------------------------------
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const savedSlippage = loadFromStorage(STORAGE_KEYS.SLIPPAGE, 5);
|
|
283
|
+
const savedTokenIn = loadFromStorage(STORAGE_KEYS.TOKEN_IN, 0);
|
|
284
|
+
const savedTokenOut = loadFromStorage(STORAGE_KEYS.TOKEN_OUT, 1);
|
|
285
|
+
|
|
286
|
+
const validTokenIn = savedTokenIn >= 0 && savedTokenIn < SUPPORTED_TOKENS.length ? savedTokenIn : 0;
|
|
287
|
+
const validTokenOut = savedTokenOut >= 0 && savedTokenOut < SUPPORTED_TOKENS.length ? savedTokenOut : 1;
|
|
288
|
+
|
|
289
|
+
setSlippage(savedSlippage);
|
|
290
|
+
setTokenInIndex(validTokenIn);
|
|
291
|
+
setTokenOutIndex(validTokenOut !== validTokenIn ? validTokenOut : validTokenIn === 0 ? 1 : 0);
|
|
292
|
+
setIsInitialized(true);
|
|
293
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
294
|
+
}, []);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (isInitialized) saveToStorage(STORAGE_KEYS.SLIPPAGE, slippage);
|
|
298
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
299
|
+
}, [slippage, isInitialized]);
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (isInitialized) saveToStorage(STORAGE_KEYS.TOKEN_IN, tokenInIndex);
|
|
303
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
304
|
+
}, [tokenInIndex, isInitialized]);
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (isInitialized) saveToStorage(STORAGE_KEYS.TOKEN_OUT, tokenOutIndex);
|
|
308
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
309
|
+
}, [tokenOutIndex, isInitialized]);
|
|
310
|
+
|
|
311
|
+
// -------------------------------------------------------------------------
|
|
312
|
+
// Load balance
|
|
313
|
+
// -------------------------------------------------------------------------
|
|
314
|
+
const loadBalance = useCallback(async () => {
|
|
315
|
+
if (!walletUser?.address || !isInitialized) return;
|
|
316
|
+
setIsLoadingBalance(true);
|
|
317
|
+
try {
|
|
318
|
+
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
319
|
+
const contract = new ethers.Contract(tokenIn.address, ERC20_ABI, provider);
|
|
320
|
+
const balance = await contract.balanceOf(walletUser.address);
|
|
321
|
+
setTokenInBalance(ethers.formatUnits(balance, tokenIn.decimals));
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error('[world-swap] Balance error:', error);
|
|
324
|
+
setTokenInBalance('0');
|
|
325
|
+
} finally {
|
|
326
|
+
setIsLoadingBalance(false);
|
|
327
|
+
}
|
|
328
|
+
}, [walletUser, tokenIn, isInitialized, rpcUrl]);
|
|
329
|
+
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
loadBalance();
|
|
332
|
+
}, [loadBalance]);
|
|
333
|
+
|
|
334
|
+
// -------------------------------------------------------------------------
|
|
335
|
+
// Fetch quote
|
|
336
|
+
// -------------------------------------------------------------------------
|
|
337
|
+
const fetchQuote = useCallback(async () => {
|
|
338
|
+
if (!amountIn || parseFloat(amountIn) <= 0) {
|
|
339
|
+
setQuote(null);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
setIsLoadingQuote(true);
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const response = await fetch(`${apiBasePath}/quote`, {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'Content-Type': 'application/json' },
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
token_in: tokenIn.symbol,
|
|
351
|
+
token_out: tokenOut.symbol,
|
|
352
|
+
token_in_address: tokenIn.address,
|
|
353
|
+
token_out_address: tokenOut.address,
|
|
354
|
+
amount_in: parseFloat(amountIn),
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const result = await response.json();
|
|
359
|
+
|
|
360
|
+
if (result.success) {
|
|
361
|
+
setQuote(result.quote);
|
|
362
|
+
setSwapResult(null);
|
|
363
|
+
} else {
|
|
364
|
+
setQuote(null);
|
|
365
|
+
if (result.error === 'NO_LIQUIDITY') {
|
|
366
|
+
setSwapResult({
|
|
367
|
+
success: false,
|
|
368
|
+
message: `⚠️ No liquidity for ${tokenIn.symbol}/${tokenOut.symbol}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
console.error('[world-swap] Quote error:', error);
|
|
374
|
+
setQuote(null);
|
|
375
|
+
} finally {
|
|
376
|
+
setIsLoadingQuote(false);
|
|
377
|
+
}
|
|
378
|
+
}, [amountIn, tokenIn, tokenOut, apiBasePath]);
|
|
379
|
+
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
const timer = setTimeout(() => {
|
|
382
|
+
if (amountIn && parseFloat(amountIn) > 0) fetchQuote();
|
|
383
|
+
}, 500);
|
|
384
|
+
return () => clearTimeout(timer);
|
|
385
|
+
}, [amountIn, tokenIn, tokenOut, fetchQuote]);
|
|
386
|
+
|
|
387
|
+
// -------------------------------------------------------------------------
|
|
388
|
+
// Swap transaction
|
|
389
|
+
// -------------------------------------------------------------------------
|
|
390
|
+
const handleSwap = async () => {
|
|
391
|
+
if (!walletUser?.address || !amountIn || parseFloat(amountIn) <= 0) {
|
|
392
|
+
setSwapResult({ success: false, message: 'Please enter a valid amount' });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!quote) {
|
|
397
|
+
setSwapResult({ success: false, message: 'Please wait for price quote' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (parseFloat(amountIn) > parseFloat(tokenInBalance)) {
|
|
402
|
+
setSwapResult({
|
|
403
|
+
success: false,
|
|
404
|
+
message: `Insufficient balance. You have ${formatBalance(tokenInBalance)} ${tokenIn.symbol}`,
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
setIsLoading(true);
|
|
410
|
+
setSwapResult(null);
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const amountInWei = ethers.parseUnits(amountIn.toString(), tokenIn.decimals);
|
|
414
|
+
|
|
415
|
+
const externalTax = tokenIn.externalTaxPercent || 0;
|
|
416
|
+
const effectiveSlippage = slippage + externalTax;
|
|
417
|
+
const minAmountOut = quote.amount_out * (1 - effectiveSlippage / 100);
|
|
418
|
+
const minAmountOutWei = ethers.parseUnits(minAmountOut.toFixed(tokenOut.decimals), tokenOut.decimals);
|
|
419
|
+
|
|
420
|
+
const tokenInAddress = ethers.getAddress(tokenIn.address);
|
|
421
|
+
const tokenOutAddress = ethers.getAddress(tokenOut.address);
|
|
422
|
+
|
|
423
|
+
const isMultiHop = quote.route_type === 'multi-hop' && quote.path && quote.path.length > 2;
|
|
424
|
+
|
|
425
|
+
let swapTransaction;
|
|
426
|
+
|
|
427
|
+
if (isMultiHop && swapMode === 'v2') {
|
|
428
|
+
const pathAddresses = quote.path.map((addr) => ethers.getAddress(addr));
|
|
429
|
+
swapTransaction = {
|
|
430
|
+
address: SWAP_CONTRACT_ADDRESS,
|
|
431
|
+
abi: SWAP_ABI_MULTIHOP,
|
|
432
|
+
functionName: 'executeSwapMultiHop',
|
|
433
|
+
args: [pathAddresses, amountInWei.toString(), minAmountOutWei.toString()],
|
|
434
|
+
};
|
|
435
|
+
} else if (swapMode === 'router') {
|
|
436
|
+
const poolFee = quote.v3_fee_tier || tokenIn.v3FeeTier || tokenOut.v3FeeTier || 10000;
|
|
437
|
+
swapTransaction = {
|
|
438
|
+
address: SWAP_CONTRACT_ADDRESS,
|
|
439
|
+
abi: SWAP_ABI_ROUTER,
|
|
440
|
+
functionName: 'swap',
|
|
441
|
+
args: [tokenInAddress, tokenOutAddress, amountInWei.toString(), minAmountOutWei.toString(), poolFee],
|
|
442
|
+
};
|
|
443
|
+
} else if (swapMode === 'v3') {
|
|
444
|
+
const poolFee = quote.v3_fee_tier || tokenIn.v3FeeTier || tokenOut.v3FeeTier || 10000;
|
|
445
|
+
swapTransaction = {
|
|
446
|
+
address: SWAP_CONTRACT_ADDRESS,
|
|
447
|
+
abi: SWAP_ABI_V3,
|
|
448
|
+
functionName: 'executeSwap',
|
|
449
|
+
args: [tokenInAddress, tokenOutAddress, amountInWei.toString(), minAmountOutWei.toString(), poolFee],
|
|
450
|
+
};
|
|
451
|
+
} else {
|
|
452
|
+
swapTransaction = {
|
|
453
|
+
address: SWAP_CONTRACT_ADDRESS,
|
|
454
|
+
abi: SWAP_ABI_DIRECT,
|
|
455
|
+
functionName: 'executeSwap',
|
|
456
|
+
args: [tokenInAddress, tokenOutAddress, amountInWei.toString(), minAmountOutWei.toString()],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const payload = {
|
|
461
|
+
transaction: [
|
|
462
|
+
{
|
|
463
|
+
address: tokenInAddress,
|
|
464
|
+
abi: TRANSFER_ABI,
|
|
465
|
+
functionName: 'transfer',
|
|
466
|
+
args: [SWAP_CONTRACT_ADDRESS, amountInWei.toString()],
|
|
467
|
+
},
|
|
468
|
+
swapTransaction,
|
|
469
|
+
],
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const result = await MiniKit.commandsAsync.sendTransaction(payload);
|
|
473
|
+
|
|
474
|
+
if (result.finalPayload?.status === 'error') {
|
|
475
|
+
throw new Error(result.finalPayload?.error_code || 'Transaction failed');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const txHash = result.finalPayload?.transaction_id || result.finalPayload?.transactionId || null;
|
|
479
|
+
|
|
480
|
+
setSwapResult({
|
|
481
|
+
success: true,
|
|
482
|
+
message: `🎉 Swap successful! You received ~${formatBalance(quote.amount_out)} ${tokenOut.symbol}`,
|
|
483
|
+
txHash,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
setAmountIn('');
|
|
487
|
+
setQuote(null);
|
|
488
|
+
setTimeout(loadBalance, 3000);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.error('[world-swap] Error:', error);
|
|
491
|
+
setSwapResult({ success: false, message: error.message || 'Swap failed' });
|
|
492
|
+
} finally {
|
|
493
|
+
setIsLoading(false);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// -------------------------------------------------------------------------
|
|
498
|
+
// Handlers
|
|
499
|
+
// -------------------------------------------------------------------------
|
|
500
|
+
const handleSwapDirection = () => {
|
|
501
|
+
const tempIndex = tokenInIndex;
|
|
502
|
+
setTokenInIndex(tokenOutIndex);
|
|
503
|
+
setTokenOutIndex(tempIndex);
|
|
504
|
+
setAmountIn('');
|
|
505
|
+
setQuote(null);
|
|
506
|
+
setSwapResult(null);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const handleSelectTokenIn = (index) => {
|
|
510
|
+
if (index === tokenOutIndex) setTokenOutIndex(tokenInIndex);
|
|
511
|
+
setTokenInIndex(index);
|
|
512
|
+
setShowTokenInDropdown(false);
|
|
513
|
+
setAmountIn('');
|
|
514
|
+
setQuote(null);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const handleSelectTokenOut = (index) => {
|
|
518
|
+
if (index === tokenInIndex) setTokenInIndex(tokenOutIndex);
|
|
519
|
+
setTokenOutIndex(index);
|
|
520
|
+
setShowTokenOutDropdown(false);
|
|
521
|
+
setQuote(null);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const handleSetMax = () => {
|
|
525
|
+
if (parseFloat(tokenInBalance) > 0) setAmountIn(tokenInBalance);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const handleSlippageChange = (newSlippage) => setSlippage(newSlippage);
|
|
529
|
+
|
|
530
|
+
const closeAllDropdowns = () => {
|
|
531
|
+
setShowTokenInDropdown(false);
|
|
532
|
+
setShowTokenOutDropdown(false);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
// Render
|
|
537
|
+
// -------------------------------------------------------------------------
|
|
538
|
+
if (!walletUser) {
|
|
539
|
+
return (
|
|
540
|
+
<div className="text-center">
|
|
541
|
+
<div className="flex justify-between items-center mb-4">
|
|
542
|
+
<div>
|
|
543
|
+
<div className="flex items-center justify-center gap-2 mb-4">
|
|
544
|
+
<ShuffleIcon className="w-6 h-6 text-orange-400" />
|
|
545
|
+
<h2 className="text-xl font-bold text-orange-400">Swap Tokens</h2>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
<p className="text-gray-400">Connect your wallet to swap</p>
|
|
550
|
+
</div>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<div className="w-full max-w-sm mx-auto px-2">
|
|
556
|
+
{/* Header */}
|
|
557
|
+
<div className="flex justify-between items-center mb-4">
|
|
558
|
+
<div>
|
|
559
|
+
<div className="flex items-center justify-center gap-2 mb-4">
|
|
560
|
+
<ShuffleIcon className="w-6 h-6 text-orange-400" />
|
|
561
|
+
<h2 className="text-xl font-bold text-orange-400">{title}</h2>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
<button
|
|
565
|
+
onClick={() => setShowSettings(!showSettings)}
|
|
566
|
+
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
|
567
|
+
>
|
|
568
|
+
⚙️
|
|
569
|
+
</button>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
{/* Balance */}
|
|
573
|
+
<div className="mb-3 bg-purple-900/30 rounded-xl p-2 border border-purple-700/50">
|
|
574
|
+
<div className="flex justify-between items-center">
|
|
575
|
+
<span className="text-gray-400 text-xs">Your {tokenIn.symbol}:</span>
|
|
576
|
+
{isLoadingBalance ? (
|
|
577
|
+
<span className="text-gray-400 text-sm">Loading...</span>
|
|
578
|
+
) : (
|
|
579
|
+
<span className="text-orange-400 font-bold text-sm">
|
|
580
|
+
{formatBalance(tokenInBalance)} {tokenIn.symbol}
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
{/* Settings */}
|
|
587
|
+
{showSettings && (
|
|
588
|
+
<div className="mb-3 bg-gray-800/50 rounded-xl p-3 border border-gray-700">
|
|
589
|
+
<div className="flex justify-between items-center">
|
|
590
|
+
<span className="text-gray-400 text-xs">Slippage</span>
|
|
591
|
+
<div className="flex gap-1 flex-wrap justify-end">
|
|
592
|
+
{[1, 3, 5, 10, 15, 20].map((s) => (
|
|
593
|
+
<button
|
|
594
|
+
key={s}
|
|
595
|
+
onClick={() => handleSlippageChange(s)}
|
|
596
|
+
className={`px-2 py-1 rounded text-xs ${slippage === s ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300'}`}
|
|
597
|
+
>
|
|
598
|
+
{s}%
|
|
599
|
+
</button>
|
|
600
|
+
))}
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
<p className="text-gray-500 text-xs mt-2">Current: {slippage}% ✓ saved</p>
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
|
|
607
|
+
{/* Result */}
|
|
608
|
+
{swapResult && (
|
|
609
|
+
<div
|
|
610
|
+
className={`mb-3 p-3 rounded-xl text-sm ${
|
|
611
|
+
swapResult.success
|
|
612
|
+
? 'bg-green-500/20 text-green-400 border border-green-500/50'
|
|
613
|
+
: 'bg-red-500/20 text-red-400 border border-red-500/50'
|
|
614
|
+
}`}
|
|
615
|
+
>
|
|
616
|
+
<p>{swapResult.message}</p>
|
|
617
|
+
{swapResult.txHash && (
|
|
618
|
+
<a
|
|
619
|
+
href={`${explorerUrl}/tx/${swapResult.txHash}`}
|
|
620
|
+
target="_blank"
|
|
621
|
+
rel="noopener noreferrer"
|
|
622
|
+
className="text-xs underline opacity-70 mt-1 block"
|
|
623
|
+
>
|
|
624
|
+
View on explorer →
|
|
625
|
+
</a>
|
|
626
|
+
)}
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
|
|
630
|
+
{/* FROM */}
|
|
631
|
+
<div className="bg-gray-800/50 rounded-xl p-3 border border-gray-700">
|
|
632
|
+
<div className="flex justify-between items-center mb-1">
|
|
633
|
+
<label className="text-gray-400 text-xs">You Pay</label>
|
|
634
|
+
<button
|
|
635
|
+
onClick={handleSetMax}
|
|
636
|
+
disabled={isLoading || parseFloat(tokenInBalance) === 0}
|
|
637
|
+
className="text-xs text-purple-400 hover:text-purple-300 disabled:opacity-50"
|
|
638
|
+
>
|
|
639
|
+
MAX
|
|
640
|
+
</button>
|
|
641
|
+
</div>
|
|
642
|
+
<div className="flex items-center gap-2">
|
|
643
|
+
<div className="relative">
|
|
644
|
+
<button
|
|
645
|
+
onClick={(e) => {
|
|
646
|
+
e.stopPropagation();
|
|
647
|
+
setShowTokenOutDropdown(false);
|
|
648
|
+
setShowTokenInDropdown(!showTokenInDropdown);
|
|
649
|
+
}}
|
|
650
|
+
disabled={isLoading}
|
|
651
|
+
className={`flex items-center gap-2 bg-gradient-to-r ${tokenIn.gradient} px-3 py-2 rounded-lg transition-all hover:opacity-90 disabled:opacity-50`}
|
|
652
|
+
>
|
|
653
|
+
<TokenIcon token={tokenIn} size={24} ImageComponent={ImageComponent} />
|
|
654
|
+
<span className="text-white font-bold text-sm">{tokenIn.symbol}</span>
|
|
655
|
+
<span className="text-white/60 text-xs">▼</span>
|
|
656
|
+
</button>
|
|
657
|
+
|
|
658
|
+
{showTokenInDropdown && (
|
|
659
|
+
<>
|
|
660
|
+
<div className="fixed inset-0 z-40" onClick={closeAllDropdowns} />
|
|
661
|
+
<div className="absolute top-full left-0 mt-2 bg-gray-800 border border-gray-700 rounded-xl shadow-xl z-50 min-w-[200px] overflow-hidden">
|
|
662
|
+
{SUPPORTED_TOKENS.map((token, index) => (
|
|
663
|
+
<button
|
|
664
|
+
key={token.symbol}
|
|
665
|
+
onClick={(e) => {
|
|
666
|
+
e.stopPropagation();
|
|
667
|
+
handleSelectTokenIn(index);
|
|
668
|
+
}}
|
|
669
|
+
className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-700 transition-colors ${
|
|
670
|
+
index === tokenInIndex ? 'bg-gray-700' : ''
|
|
671
|
+
}`}
|
|
672
|
+
>
|
|
673
|
+
<TokenIcon token={token} size={32} ImageComponent={ImageComponent} />
|
|
674
|
+
<div className="text-left flex-1">
|
|
675
|
+
<p className="text-white font-semibold text-sm">{token.symbol}</p>
|
|
676
|
+
<p className="text-gray-400 text-xs">{token.name}</p>
|
|
677
|
+
</div>
|
|
678
|
+
{index === tokenInIndex && <span className="text-green-400">✓</span>}
|
|
679
|
+
</button>
|
|
680
|
+
))}
|
|
681
|
+
</div>
|
|
682
|
+
</>
|
|
683
|
+
)}
|
|
684
|
+
</div>
|
|
685
|
+
|
|
686
|
+
<input
|
|
687
|
+
type="number"
|
|
688
|
+
placeholder="0.0"
|
|
689
|
+
value={amountIn}
|
|
690
|
+
onChange={(e) => setAmountIn(e.target.value)}
|
|
691
|
+
disabled={isLoading}
|
|
692
|
+
step="0.000001"
|
|
693
|
+
min="0"
|
|
694
|
+
className="flex-1 min-w-0 bg-transparent text-white text-right text-xl font-mono focus:outline-none"
|
|
695
|
+
/>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
{/* Swap direction */}
|
|
700
|
+
<div className="flex justify-center relative z-20 h-0">
|
|
701
|
+
<button
|
|
702
|
+
onClick={handleSwapDirection}
|
|
703
|
+
disabled={isLoading}
|
|
704
|
+
className="bg-gray-700 hover:bg-gray-600 p-4 rounded-full border-4 border-gray-900 transition-all hover:scale-110 disabled:opacity-50 shadow-lg -translate-y-1/2 flex items-center justify-center"
|
|
705
|
+
>
|
|
706
|
+
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
707
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
|
708
|
+
</svg>
|
|
709
|
+
</button>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
{/* TO */}
|
|
713
|
+
<div className="mb-3 bg-gray-800/50 rounded-xl p-3 border border-gray-700">
|
|
714
|
+
<div className="flex justify-between items-center mb-1">
|
|
715
|
+
<label className="text-gray-400 text-xs">You Receive</label>
|
|
716
|
+
{quote && quote.price_impact > 1 && (
|
|
717
|
+
<span className={`text-xs ${quote.price_impact > 5 ? 'text-red-400' : 'text-yellow-400'}`}>
|
|
718
|
+
{quote.price_impact.toFixed(1)}% impact
|
|
719
|
+
</span>
|
|
720
|
+
)}
|
|
721
|
+
</div>
|
|
722
|
+
<div className="flex items-center gap-2">
|
|
723
|
+
<div className="relative">
|
|
724
|
+
<button
|
|
725
|
+
onClick={(e) => {
|
|
726
|
+
e.stopPropagation();
|
|
727
|
+
setShowTokenInDropdown(false);
|
|
728
|
+
setShowTokenOutDropdown(!showTokenOutDropdown);
|
|
729
|
+
}}
|
|
730
|
+
disabled={isLoading}
|
|
731
|
+
className={`flex items-center gap-2 bg-gradient-to-r ${tokenOut.gradient} px-3 py-2 rounded-lg transition-all hover:opacity-90 disabled:opacity-50`}
|
|
732
|
+
>
|
|
733
|
+
<TokenIcon token={tokenOut} size={24} ImageComponent={ImageComponent} />
|
|
734
|
+
<span className="text-white font-bold text-sm">{tokenOut.symbol}</span>
|
|
735
|
+
<span className="text-white/60 text-xs">▼</span>
|
|
736
|
+
</button>
|
|
737
|
+
|
|
738
|
+
{showTokenOutDropdown && (
|
|
739
|
+
<>
|
|
740
|
+
<div className="fixed inset-0 z-40" onClick={closeAllDropdowns} />
|
|
741
|
+
<div className="absolute top-full left-0 mt-2 bg-gray-800 border border-gray-700 rounded-xl shadow-xl z-50 min-w-[200px] overflow-hidden">
|
|
742
|
+
{SUPPORTED_TOKENS.map((token, index) => (
|
|
743
|
+
<button
|
|
744
|
+
key={token.symbol}
|
|
745
|
+
onClick={(e) => {
|
|
746
|
+
e.stopPropagation();
|
|
747
|
+
handleSelectTokenOut(index);
|
|
748
|
+
}}
|
|
749
|
+
className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-700 transition-colors ${
|
|
750
|
+
index === tokenOutIndex ? 'bg-gray-700' : ''
|
|
751
|
+
}`}
|
|
752
|
+
>
|
|
753
|
+
<TokenIcon token={token} size={32} ImageComponent={ImageComponent} />
|
|
754
|
+
<div className="text-left flex-1">
|
|
755
|
+
<p className="text-white font-semibold text-sm">{token.symbol}</p>
|
|
756
|
+
<p className="text-gray-400 text-xs">{token.name}</p>
|
|
757
|
+
</div>
|
|
758
|
+
{index === tokenOutIndex && <span className="text-green-400">✓</span>}
|
|
759
|
+
</button>
|
|
760
|
+
))}
|
|
761
|
+
</div>
|
|
762
|
+
</>
|
|
763
|
+
)}
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div className="flex-1 text-right">
|
|
767
|
+
{isLoadingQuote ? (
|
|
768
|
+
<span className="text-gray-400 text-xl animate-pulse">...</span>
|
|
769
|
+
) : quote ? (
|
|
770
|
+
<span className="text-white text-xl font-mono">{formatBalance(quote.amount_out)}</span>
|
|
771
|
+
) : (
|
|
772
|
+
<span className="text-gray-500 text-xl">0.0</span>
|
|
773
|
+
)}
|
|
774
|
+
</div>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
|
|
778
|
+
{/* Swap button */}
|
|
779
|
+
<div className="w-full justify-center">
|
|
780
|
+
<Button
|
|
781
|
+
onClick={handleSwap}
|
|
782
|
+
disabled={isLoading || !amountIn || parseFloat(amountIn) <= 0 || !quote || isLoadingQuote}
|
|
783
|
+
>
|
|
784
|
+
{isLoading ? (
|
|
785
|
+
<span className="flex items-center justify-center gap-2">⏳ Swapping...</span>
|
|
786
|
+
) : isLoadingQuote ? (
|
|
787
|
+
<span className="flex items-center justify-center gap-2">⏳ Loading...</span>
|
|
788
|
+
) : !quote ? (
|
|
789
|
+
'Enter amount'
|
|
790
|
+
) : (
|
|
791
|
+
<span className="flex items-center justify-center gap-2">
|
|
792
|
+
<ShuffleIcon className="w-5 h-5" /> Swap {tokenIn.symbol} → {tokenOut.symbol}
|
|
793
|
+
</span>
|
|
794
|
+
)}
|
|
795
|
+
</Button>
|
|
796
|
+
</div>
|
|
797
|
+
|
|
798
|
+
{/* Quote details */}
|
|
799
|
+
{quote && (
|
|
800
|
+
<div className="mb-3 bg-gray-800/30 rounded-lg p-2 text-xs space-y-1">
|
|
801
|
+
<div className="flex justify-between text-gray-400">
|
|
802
|
+
<span>Rate</span>
|
|
803
|
+
<span>
|
|
804
|
+
1 {tokenIn.symbol} = {formatBalance(quote.rate)} {tokenOut.symbol}
|
|
805
|
+
</span>
|
|
806
|
+
</div>
|
|
807
|
+
<div className="flex justify-between text-gray-400">
|
|
808
|
+
<span>Fee ({quote.platform_fee_percent}%)</span>
|
|
809
|
+
<span>
|
|
810
|
+
-{formatBalance(quote.platform_fee)} {tokenIn.symbol}
|
|
811
|
+
</span>
|
|
812
|
+
</div>
|
|
813
|
+
{tokenIn.externalTaxPercent > 0 && (
|
|
814
|
+
<div className="flex justify-between text-yellow-400">
|
|
815
|
+
<span>⚠️ Token tax ({tokenIn.externalTaxPercent}%)</span>
|
|
816
|
+
<span>
|
|
817
|
+
-{formatBalance((parseFloat(amountIn) * tokenIn.externalTaxPercent) / 100)} {tokenIn.symbol}
|
|
818
|
+
</span>
|
|
819
|
+
</div>
|
|
820
|
+
)}
|
|
821
|
+
<div className="flex justify-between text-gray-400">
|
|
822
|
+
<span>Slippage</span>
|
|
823
|
+
<span>
|
|
824
|
+
{slippage}%{tokenIn.externalTaxPercent > 0 ? ` + ${tokenIn.externalTaxPercent}% tax` : ''}
|
|
825
|
+
</span>
|
|
826
|
+
</div>
|
|
827
|
+
<div className="flex justify-between text-gray-400">
|
|
828
|
+
<span>Min. received</span>
|
|
829
|
+
<span>
|
|
830
|
+
{formatBalance(quote.amount_out * (1 - (slippage + (tokenIn.externalTaxPercent || 0)) / 100))}{' '}
|
|
831
|
+
{tokenOut.symbol}
|
|
832
|
+
</span>
|
|
833
|
+
</div>
|
|
834
|
+
{swapMode !== 'v2' && quote.v3_fee_tier && (
|
|
835
|
+
<div className="flex justify-between text-blue-400">
|
|
836
|
+
<span>Pool fee tier</span>
|
|
837
|
+
<span>{quote.v3_fee_tier / 10000}%</span>
|
|
838
|
+
</div>
|
|
839
|
+
)}
|
|
840
|
+
</div>
|
|
841
|
+
)}
|
|
842
|
+
|
|
843
|
+
{/* Refresh */}
|
|
844
|
+
<button onClick={loadBalance} className="w-full mt-2 text-gray-500 hover:text-gray-300 text-xs py-1">
|
|
845
|
+
🔄 Refresh balance
|
|
846
|
+
</button>
|
|
847
|
+
</div>
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export default SwapCard;
|