@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/defaults.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @nonnux/world-swap/defaults
|
|
2
|
+
// Default configuration for the nonnux / World Chain ecosystem.
|
|
3
|
+
// Every value here can be overridden via props (client) or config (server),
|
|
4
|
+
// so third parties can plug in their own tokens, contracts and RPC.
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_RPC_URL = 'https://worldchain-mainnet.g.alchemy.com/public';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_EXPLORER_URL = 'https://worldscan.org';
|
|
9
|
+
|
|
10
|
+
// Swap + Uniswap infrastructure (World Chain mainnet).
|
|
11
|
+
export const DEFAULT_CONTRACTS = {
|
|
12
|
+
// nonnux swap contracts
|
|
13
|
+
swapV2: '0x4479A14811274D0eEB8Eb8932fA5A49b51F37852', // SliceSwapV6 (V2 only)
|
|
14
|
+
swapV3: '0x4Cf23b3C9BeB48b85bc8F01E3dAB674Ce94E180a', // SliceSwapUniV3 (V3 only)
|
|
15
|
+
swapRouter: '0x380a940524C4cC1829f654B7790B338f676C31b8', // SliceSwapRouter (V2 <-> V3)
|
|
16
|
+
|
|
17
|
+
// Uniswap V2
|
|
18
|
+
uniV2Router: '0x541aB7c31A119441eF3575F6973277DE0eF460bd',
|
|
19
|
+
uniV2Factory: '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f',
|
|
20
|
+
|
|
21
|
+
// Uniswap V3
|
|
22
|
+
uniV3Factory: '0x7a5028BDa40e7B173C278C5342087826455ea25a',
|
|
23
|
+
uniV3Quoter: '0x10158D43e6cc414deE1Bd1eB0EfC6a5cBCfF244c',
|
|
24
|
+
|
|
25
|
+
// Routing hub token
|
|
26
|
+
wld: '0x2cFc85d8E48F8EAB294be644d9E25C3030863003',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Uniswap V3 fee tiers.
|
|
30
|
+
export const V3_FEE_TIERS = {
|
|
31
|
+
LOWEST: 100, // 0.01%
|
|
32
|
+
LOW: 500, // 0.05%
|
|
33
|
+
MEDIUM: 3000, // 0.3%
|
|
34
|
+
HIGH: 10000, // 1% (common for memecoins)
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Pairs that have a direct V3 pool and should skip cross-version routing.
|
|
38
|
+
export const DEFAULT_DIRECT_V3_PAIRS = [['SUSHI', 'WLD']];
|
|
39
|
+
|
|
40
|
+
// Default platform fee, in basis points (100 = 1%).
|
|
41
|
+
export const DEFAULT_PLATFORM_FEE_BPS = 100;
|
|
42
|
+
|
|
43
|
+
// Token list. `icon` paths are app-relative (served from /public). Override
|
|
44
|
+
// `tokens` with your own list to swap a different set.
|
|
45
|
+
export const DEFAULT_TOKENS = [
|
|
46
|
+
{
|
|
47
|
+
symbol: 'SLICE',
|
|
48
|
+
name: 'UFO PIZZA',
|
|
49
|
+
address: '0x513530649BAC862c38b65e957E6EA38d3f86F3dc',
|
|
50
|
+
icon: '/tokens/slice.gif',
|
|
51
|
+
gradient: 'from-orange-500 to-yellow-500',
|
|
52
|
+
decimals: 18,
|
|
53
|
+
emoji: 'đ',
|
|
54
|
+
externalTaxPercent: 0,
|
|
55
|
+
poolVersion: 'v2',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
symbol: 'REI$',
|
|
59
|
+
name: 'REIS',
|
|
60
|
+
address: '0x10377A2e9CC81A8822885cd847e577F7F39B5574',
|
|
61
|
+
icon: '/tokens/reis.gif',
|
|
62
|
+
gradient: 'from-white via-gray-200 to-gray-300',
|
|
63
|
+
decimals: 18,
|
|
64
|
+
emoji: 'â',
|
|
65
|
+
externalTaxPercent: 0,
|
|
66
|
+
poolVersion: 'v2',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
symbol: 'PAU$',
|
|
70
|
+
name: 'PAUS',
|
|
71
|
+
address: '0x2e5f6BB8C993e64B3bC1fC9473C8B2CDEd772D06',
|
|
72
|
+
icon: '/tokens/paus.gif',
|
|
73
|
+
gradient: 'from-emerald-900 via-teal-900 to-slate-900',
|
|
74
|
+
decimals: 18,
|
|
75
|
+
emoji: 'âŊ',
|
|
76
|
+
externalTaxPercent: 0,
|
|
77
|
+
poolVersion: 'v2',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
symbol: 'CONTO$',
|
|
81
|
+
name: 'CONTOS',
|
|
82
|
+
address: '0xE67Fb81ca7d20eB90bA3a53220018a9A7f870de8',
|
|
83
|
+
icon: '/tokens/contos.gif',
|
|
84
|
+
gradient: 'from-cyan-500 via-teal-600 to-slate-800',
|
|
85
|
+
decimals: 18,
|
|
86
|
+
emoji: 'Šī¸',
|
|
87
|
+
externalTaxPercent: 0,
|
|
88
|
+
poolVersion: 'v2',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
symbol: 'WLC',
|
|
92
|
+
name: 'WorldChain OGs',
|
|
93
|
+
address: '0x9d59Bb9d85Ae4622116Ad11055B5be4349b67dc2',
|
|
94
|
+
icon: '/tokens/wlc.gif',
|
|
95
|
+
gradient: 'from-green-500 to-emerald-500',
|
|
96
|
+
decimals: 18,
|
|
97
|
+
emoji: 'đĒ',
|
|
98
|
+
externalTaxPercent: 0,
|
|
99
|
+
poolVersion: 'v2',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
symbol: 'CDT',
|
|
103
|
+
name: 'TRIBO',
|
|
104
|
+
address: '0x3Cb880f7ac84950c369e700deE2778d023b0C52d',
|
|
105
|
+
icon: '/tokens/cdt.gif',
|
|
106
|
+
gradient: 'from-green-500 to-emerald-500',
|
|
107
|
+
decimals: 18,
|
|
108
|
+
emoji: 'đ´',
|
|
109
|
+
externalTaxPercent: 2,
|
|
110
|
+
poolVersion: 'v2',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
symbol: '$PVO',
|
|
114
|
+
name: 'PaVcOin',
|
|
115
|
+
address: '0xE977de70dd1F571Aa563E41525C28b4F1eDB69ba',
|
|
116
|
+
icon: '/tokens/pvo.gif',
|
|
117
|
+
gradient: 'from-yellow-500 to-red-500',
|
|
118
|
+
decimals: 18,
|
|
119
|
+
emoji: 'đĻ',
|
|
120
|
+
externalTaxPercent: 0,
|
|
121
|
+
poolVersion: 'v2',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
symbol: 'RFX2.0',
|
|
125
|
+
name: 'Roflex 2.0 MemeToken',
|
|
126
|
+
address: '0x03995cE5Ad612a2cC3E4DFBEEb0EeD7BC165749b',
|
|
127
|
+
icon: '/tokens/rfx2.0.gif',
|
|
128
|
+
gradient: 'from-blue-500 to-slate-500',
|
|
129
|
+
decimals: 18,
|
|
130
|
+
emoji: 'đ˛',
|
|
131
|
+
externalTaxPercent: 0,
|
|
132
|
+
poolVersion: 'v2',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
symbol: 'SWIFH',
|
|
136
|
+
name: 'Samwifhat',
|
|
137
|
+
address: '0x06B4C6830bb19BF07A88ff96758003074EaE9c52',
|
|
138
|
+
icon: '/tokens/swifh.gif',
|
|
139
|
+
gradient: 'from-white-500 to-pink-500',
|
|
140
|
+
decimals: 18,
|
|
141
|
+
emoji: 'đŠ',
|
|
142
|
+
externalTaxPercent: 0,
|
|
143
|
+
poolVersion: 'v2',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
symbol: 'WLD',
|
|
147
|
+
name: 'Worldcoin',
|
|
148
|
+
address: '0x2cFc85d8E48F8EAB294be644d9E25C3030863003',
|
|
149
|
+
icon: '/tokens/wld.gif',
|
|
150
|
+
gradient: 'from-blue-500 to-purple-500',
|
|
151
|
+
decimals: 18,
|
|
152
|
+
emoji: 'đ',
|
|
153
|
+
externalTaxPercent: 0,
|
|
154
|
+
poolVersion: 'v2',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
symbol: 'WETH',
|
|
158
|
+
name: 'Wrapped Ether',
|
|
159
|
+
address: '0x4200000000000000000000000000000000000006',
|
|
160
|
+
icon: '/tokens/weth.gif',
|
|
161
|
+
gradient: 'from-purple-500 to-pink-500',
|
|
162
|
+
decimals: 18,
|
|
163
|
+
emoji: 'đš',
|
|
164
|
+
externalTaxPercent: 0,
|
|
165
|
+
poolVersion: 'v2',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
symbol: 'SUSHI',
|
|
169
|
+
name: 'SUSHI Token',
|
|
170
|
+
address: '0xab09A728E53d3d6BC438BE95eeD46Da0Bbe7FB38',
|
|
171
|
+
icon: '/tokens/sushi.gif',
|
|
172
|
+
gradient: 'from-pink-500 to-purple-500',
|
|
173
|
+
decimals: 18,
|
|
174
|
+
emoji: 'đŖ',
|
|
175
|
+
externalTaxPercent: 0,
|
|
176
|
+
poolVersion: 'v3', // SUSHI only has a V3 pool
|
|
177
|
+
v3FeeTier: 10000, // 1% fee tier
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
// Find a token by symbol within a given list (case-insensitive).
|
|
182
|
+
export const getTokenBySymbol = (tokens, symbol) =>
|
|
183
|
+
tokens.find((t) => t.symbol.toLowerCase() === String(symbol).toLowerCase());
|
|
184
|
+
|
|
185
|
+
// Find a token by address within a given list (case-insensitive).
|
|
186
|
+
export const getTokenByAddress = (tokens, address) =>
|
|
187
|
+
tokens.find((t) => t.address.toLowerCase() === String(address).toLowerCase());
|
package/src/server.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// @nonnux/world-swap/server
|
|
2
|
+
// Swap quote API for World Chain (Uniswap V2 / V3 / cross-version).
|
|
3
|
+
// Self-contained: only `ethers` + a public RPC. No database, no secrets.
|
|
4
|
+
//
|
|
5
|
+
// Usage in a Next.js app (app/api/swap/quote/route.js):
|
|
6
|
+
//
|
|
7
|
+
// // Batteries-included (nonnux / World Chain defaults):
|
|
8
|
+
// export { POST, GET } from '@nonnux/world-swap/server';
|
|
9
|
+
//
|
|
10
|
+
// // Or with your own tokens / contracts:
|
|
11
|
+
// import { createSwapQuoteHandler } from '@nonnux/world-swap/server';
|
|
12
|
+
// import { MY_TOKENS } from '@/config/tokens';
|
|
13
|
+
// export const { POST, GET } = createSwapQuoteHandler({ tokens: MY_TOKENS });
|
|
14
|
+
|
|
15
|
+
import { ethers } from 'ethers';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_TOKENS,
|
|
18
|
+
DEFAULT_CONTRACTS,
|
|
19
|
+
DEFAULT_RPC_URL,
|
|
20
|
+
DEFAULT_DIRECT_V3_PAIRS,
|
|
21
|
+
DEFAULT_PLATFORM_FEE_BPS,
|
|
22
|
+
V3_FEE_TIERS,
|
|
23
|
+
getTokenBySymbol,
|
|
24
|
+
} from './defaults.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// ABIs
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const ROUTER_V2_ABI = [
|
|
30
|
+
'function getAmountsOut(uint256 amountIn, address[] calldata path) external view returns (uint256[] memory amounts)',
|
|
31
|
+
];
|
|
32
|
+
const FACTORY_V2_ABI = [
|
|
33
|
+
'function getPair(address tokenA, address tokenB) external view returns (address pair)',
|
|
34
|
+
];
|
|
35
|
+
const PAIR_V2_ABI = [
|
|
36
|
+
'function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
|
|
37
|
+
'function token0() external view returns (address)',
|
|
38
|
+
];
|
|
39
|
+
const FACTORY_V3_ABI = [
|
|
40
|
+
'function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool)',
|
|
41
|
+
];
|
|
42
|
+
const QUOTER_V3_ABI = [
|
|
43
|
+
'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96)) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
|
|
44
|
+
];
|
|
45
|
+
const POOL_V3_ABI = ['function liquidity() external view returns (uint128)'];
|
|
46
|
+
|
|
47
|
+
const BPS_DENOMINATOR = 10000n;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Price impact (V2)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
async function calculatePriceImpactV2(provider, factory, tokenInAddress, tokenOutAddress, amountInWei, amountOutWei) {
|
|
53
|
+
try {
|
|
54
|
+
const pairAddress = await factory.getPair(tokenInAddress, tokenOutAddress);
|
|
55
|
+
if (pairAddress === ethers.ZeroAddress) return 0;
|
|
56
|
+
|
|
57
|
+
const pair = new ethers.Contract(pairAddress, PAIR_V2_ABI, provider);
|
|
58
|
+
const [reserve0, reserve1] = await pair.getReserves();
|
|
59
|
+
const token0 = await pair.token0();
|
|
60
|
+
|
|
61
|
+
const isToken0 = token0.toLowerCase() === tokenInAddress.toLowerCase();
|
|
62
|
+
const reserveIn = isToken0 ? reserve0 : reserve1;
|
|
63
|
+
const reserveOut = isToken0 ? reserve1 : reserve0;
|
|
64
|
+
|
|
65
|
+
const spotPrice = Number(reserveOut) / Number(reserveIn);
|
|
66
|
+
const effectivePrice = Number(amountOutWei) / Number(amountInWei);
|
|
67
|
+
const priceImpact = ((spotPrice - effectivePrice) / spotPrice) * 100;
|
|
68
|
+
|
|
69
|
+
return Math.max(0, priceImpact);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Quote: V2
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
async function getQuoteV2(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, tokenInSymbol, tokenOutSymbol) {
|
|
79
|
+
const { provider, c, WLD } = ctx;
|
|
80
|
+
const factory = new ethers.Contract(c.uniV2Factory, FACTORY_V2_ABI, provider);
|
|
81
|
+
const router = new ethers.Contract(c.uniV2Router, ROUTER_V2_ABI, provider);
|
|
82
|
+
|
|
83
|
+
let amountOutWei = 0n;
|
|
84
|
+
let routeType = 'direct';
|
|
85
|
+
let pathDisplay = `${tokenInSymbol} â ${tokenOutSymbol}`;
|
|
86
|
+
let path = [tokenInAddress, tokenOutAddress];
|
|
87
|
+
let priceImpact = 0;
|
|
88
|
+
|
|
89
|
+
const directPair = await factory.getPair(tokenInAddress, tokenOutAddress);
|
|
90
|
+
|
|
91
|
+
if (directPair !== ethers.ZeroAddress) {
|
|
92
|
+
try {
|
|
93
|
+
const directPath = [tokenInAddress, tokenOutAddress];
|
|
94
|
+
const amounts = await router.getAmountsOut(amountAfterFeeWei, directPath);
|
|
95
|
+
amountOutWei = amounts[amounts.length - 1];
|
|
96
|
+
path = directPath;
|
|
97
|
+
priceImpact = await calculatePriceImpactV2(provider, factory, tokenInAddress, tokenOutAddress, amountAfterFeeWei, amountOutWei);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
/* fall through to multi-hop */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
amountOutWei === 0n &&
|
|
105
|
+
tokenInAddress.toLowerCase() !== WLD.toLowerCase() &&
|
|
106
|
+
tokenOutAddress.toLowerCase() !== WLD.toLowerCase()
|
|
107
|
+
) {
|
|
108
|
+
try {
|
|
109
|
+
const multiHopPath = [tokenInAddress, WLD, tokenOutAddress];
|
|
110
|
+
const amounts = await router.getAmountsOut(amountAfterFeeWei, multiHopPath);
|
|
111
|
+
amountOutWei = amounts[amounts.length - 1];
|
|
112
|
+
routeType = 'multi-hop';
|
|
113
|
+
pathDisplay = `${tokenInSymbol} â WLD â ${tokenOutSymbol}`;
|
|
114
|
+
path = multiHopPath;
|
|
115
|
+
|
|
116
|
+
const wldAmount = amounts[1];
|
|
117
|
+
const impact1 = await calculatePriceImpactV2(provider, factory, tokenInAddress, WLD, amountAfterFeeWei, wldAmount);
|
|
118
|
+
const impact2 = await calculatePriceImpactV2(provider, factory, WLD, tokenOutAddress, wldAmount, amountOutWei);
|
|
119
|
+
priceImpact = impact1 + impact2;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
/* no route */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (amountOutWei === 0n) {
|
|
126
|
+
return { success: false, error: 'NO_LIQUIDITY', message: `No V2 pool for ${tokenInSymbol}/${tokenOutSymbol}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
success: true,
|
|
131
|
+
amountOutWei,
|
|
132
|
+
routeType,
|
|
133
|
+
poolVersion: 'v2',
|
|
134
|
+
pathDisplay,
|
|
135
|
+
path,
|
|
136
|
+
priceImpact: Math.round(priceImpact * 100) / 100,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Quote: V3
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
async function getQuoteV3(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, tokenInSymbol, tokenOutSymbol, tokenConfig) {
|
|
144
|
+
const { provider, c, FEE } = ctx;
|
|
145
|
+
const factory = new ethers.Contract(c.uniV3Factory, FACTORY_V3_ABI, provider);
|
|
146
|
+
const quoter = new ethers.Contract(c.uniV3Quoter, QUOTER_V3_ABI, provider);
|
|
147
|
+
|
|
148
|
+
const feeTiersToTry = [tokenConfig?.v3FeeTier || FEE.HIGH, FEE.HIGH, FEE.MEDIUM, FEE.LOW];
|
|
149
|
+
|
|
150
|
+
for (const feeTier of [...new Set(feeTiersToTry)]) {
|
|
151
|
+
try {
|
|
152
|
+
const poolAddress = await factory.getPool(tokenInAddress, tokenOutAddress, feeTier);
|
|
153
|
+
|
|
154
|
+
if (poolAddress !== ethers.ZeroAddress) {
|
|
155
|
+
const pool = new ethers.Contract(poolAddress, POOL_V3_ABI, provider);
|
|
156
|
+
const liquidity = await pool.liquidity();
|
|
157
|
+
|
|
158
|
+
if (liquidity > 0n) {
|
|
159
|
+
const quoteResult = await quoter.quoteExactInputSingle.staticCall({
|
|
160
|
+
tokenIn: tokenInAddress,
|
|
161
|
+
tokenOut: tokenOutAddress,
|
|
162
|
+
amountIn: amountAfterFeeWei,
|
|
163
|
+
fee: feeTier,
|
|
164
|
+
sqrtPriceLimitX96: 0,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
let priceImpact = 0;
|
|
168
|
+
try {
|
|
169
|
+
const smallAmount = ethers.parseEther('0.001');
|
|
170
|
+
if (amountAfterFeeWei > smallAmount) {
|
|
171
|
+
const spotQuote = await quoter.quoteExactInputSingle.staticCall({
|
|
172
|
+
tokenIn: tokenInAddress,
|
|
173
|
+
tokenOut: tokenOutAddress,
|
|
174
|
+
amountIn: smallAmount,
|
|
175
|
+
fee: feeTier,
|
|
176
|
+
sqrtPriceLimitX96: 0,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const spotPrice = Number(spotQuote[0]) / Number(smallAmount);
|
|
180
|
+
const effectivePrice = Number(quoteResult[0]) / Number(amountAfterFeeWei);
|
|
181
|
+
priceImpact = ((spotPrice - effectivePrice) / spotPrice) * 100;
|
|
182
|
+
priceImpact = Math.max(0, Math.round(priceImpact * 100) / 100);
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
priceImpact = 0.1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
success: true,
|
|
190
|
+
amountOutWei: quoteResult[0],
|
|
191
|
+
routeType: 'direct',
|
|
192
|
+
poolVersion: 'v3',
|
|
193
|
+
pathDisplay: `${tokenInSymbol} â ${tokenOutSymbol}`,
|
|
194
|
+
priceImpact,
|
|
195
|
+
v3FeeTier: feeTier,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
/* try next fee tier */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { success: false, error: 'NO_LIQUIDITY', message: `No V3 pool for ${tokenInSymbol}/${tokenOutSymbol}` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Quote: cross-version (V3 <-> V2 via WLD)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
async function getQuoteCrossVersion(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, tokenInSymbol, tokenOutSymbol, direction, v3TokenConfig) {
|
|
211
|
+
const { provider, c, FEE, WLD } = ctx;
|
|
212
|
+
const factoryV2 = new ethers.Contract(c.uniV2Factory, FACTORY_V2_ABI, provider);
|
|
213
|
+
const routerV2 = new ethers.Contract(c.uniV2Router, ROUTER_V2_ABI, provider);
|
|
214
|
+
const factoryV3 = new ethers.Contract(c.uniV3Factory, FACTORY_V3_ABI, provider);
|
|
215
|
+
const quoterV3 = new ethers.Contract(c.uniV3Quoter, QUOTER_V3_ABI, provider);
|
|
216
|
+
|
|
217
|
+
let intermediateWldWei = 0n;
|
|
218
|
+
let finalAmountOutWei = 0n;
|
|
219
|
+
let v3FeeTier = 0;
|
|
220
|
+
|
|
221
|
+
if (direction === 'v3_to_v2') {
|
|
222
|
+
const feeTiersToTry = [v3TokenConfig?.v3FeeTier || FEE.HIGH, FEE.HIGH, FEE.MEDIUM];
|
|
223
|
+
|
|
224
|
+
for (const feeTier of [...new Set(feeTiersToTry)]) {
|
|
225
|
+
try {
|
|
226
|
+
const poolAddress = await factoryV3.getPool(tokenInAddress, WLD, feeTier);
|
|
227
|
+
|
|
228
|
+
if (poolAddress !== ethers.ZeroAddress) {
|
|
229
|
+
const pool = new ethers.Contract(poolAddress, POOL_V3_ABI, provider);
|
|
230
|
+
const liquidity = await pool.liquidity();
|
|
231
|
+
|
|
232
|
+
if (liquidity > 0n) {
|
|
233
|
+
const quoteResult = await quoterV3.quoteExactInputSingle.staticCall({
|
|
234
|
+
tokenIn: tokenInAddress,
|
|
235
|
+
tokenOut: WLD,
|
|
236
|
+
amountIn: amountAfterFeeWei,
|
|
237
|
+
fee: feeTier,
|
|
238
|
+
sqrtPriceLimitX96: 0,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
intermediateWldWei = quoteResult[0];
|
|
242
|
+
v3FeeTier = feeTier;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {
|
|
247
|
+
/* try next fee tier */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (intermediateWldWei === 0n) {
|
|
252
|
+
return { success: false, error: 'NO_LIQUIDITY', message: `No V3 pool for ${tokenInSymbol}/WLD` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const path = [WLD, tokenOutAddress];
|
|
257
|
+
const amounts = await routerV2.getAmountsOut(intermediateWldWei, path);
|
|
258
|
+
finalAmountOutWei = amounts[amounts.length - 1];
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { success: false, error: 'NO_LIQUIDITY', message: `No V2 pool for WLD/${tokenOutSymbol}` };
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// v2_to_v3
|
|
264
|
+
try {
|
|
265
|
+
const path = [tokenInAddress, WLD];
|
|
266
|
+
const amounts = await routerV2.getAmountsOut(amountAfterFeeWei, path);
|
|
267
|
+
intermediateWldWei = amounts[amounts.length - 1];
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { success: false, error: 'NO_LIQUIDITY', message: `No V2 pool for ${tokenInSymbol}/WLD` };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const feeTiersToTry = [v3TokenConfig?.v3FeeTier || FEE.HIGH, FEE.HIGH, FEE.MEDIUM];
|
|
273
|
+
|
|
274
|
+
for (const feeTier of [...new Set(feeTiersToTry)]) {
|
|
275
|
+
try {
|
|
276
|
+
const poolAddress = await factoryV3.getPool(WLD, tokenOutAddress, feeTier);
|
|
277
|
+
|
|
278
|
+
if (poolAddress !== ethers.ZeroAddress) {
|
|
279
|
+
const pool = new ethers.Contract(poolAddress, POOL_V3_ABI, provider);
|
|
280
|
+
const liquidity = await pool.liquidity();
|
|
281
|
+
|
|
282
|
+
if (liquidity > 0n) {
|
|
283
|
+
const quoteResult = await quoterV3.quoteExactInputSingle.staticCall({
|
|
284
|
+
tokenIn: WLD,
|
|
285
|
+
tokenOut: tokenOutAddress,
|
|
286
|
+
amountIn: intermediateWldWei,
|
|
287
|
+
fee: feeTier,
|
|
288
|
+
sqrtPriceLimitX96: 0,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
finalAmountOutWei = quoteResult[0];
|
|
292
|
+
v3FeeTier = feeTier;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (e) {
|
|
297
|
+
/* try next fee tier */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (finalAmountOutWei === 0n) {
|
|
302
|
+
return { success: false, error: 'NO_LIQUIDITY', message: `No V3 pool for WLD/${tokenOutSymbol}` };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let priceImpact = 0;
|
|
307
|
+
try {
|
|
308
|
+
if (direction === 'v2_to_v3') {
|
|
309
|
+
priceImpact = await calculatePriceImpactV2(provider, factoryV2, tokenInAddress, WLD, amountAfterFeeWei, intermediateWldWei);
|
|
310
|
+
} else {
|
|
311
|
+
priceImpact = await calculatePriceImpactV2(provider, factoryV2, WLD, tokenOutAddress, intermediateWldWei, finalAmountOutWei);
|
|
312
|
+
}
|
|
313
|
+
priceImpact += 0.1; // small allowance for the V3 leg
|
|
314
|
+
} catch (e) {
|
|
315
|
+
priceImpact = 0.5;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
success: true,
|
|
320
|
+
amountOutWei: finalAmountOutWei,
|
|
321
|
+
routeType: 'cross-version',
|
|
322
|
+
poolVersion: direction,
|
|
323
|
+
pathDisplay: `${tokenInSymbol} â WLD â ${tokenOutSymbol}`,
|
|
324
|
+
priceImpact: Math.round(priceImpact * 100) / 100,
|
|
325
|
+
intermediateAmount: parseFloat(ethers.formatEther(intermediateWldWei)),
|
|
326
|
+
v3FeeTier,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Handler factory
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
/**
|
|
334
|
+
* Build a Next.js route handler ({ POST, GET }) for swap quotes.
|
|
335
|
+
*
|
|
336
|
+
* @param {Object} [userConfig]
|
|
337
|
+
* @param {Array} [userConfig.tokens] Token list (defaults to nonnux ecosystem).
|
|
338
|
+
* @param {Object} [userConfig.contracts] Contract address overrides (merged with defaults).
|
|
339
|
+
* @param {string} [userConfig.rpcUrl] JSON-RPC URL for World Chain.
|
|
340
|
+
* @param {Array} [userConfig.directV3Pairs] Symbol pairs that use a direct V3 pool.
|
|
341
|
+
* @param {number} [userConfig.platformFeeBps] Platform fee in basis points (100 = 1%).
|
|
342
|
+
* @param {Object} [userConfig.v3FeeTiers] V3 fee tier map override.
|
|
343
|
+
*/
|
|
344
|
+
export function createSwapQuoteHandler(userConfig = {}) {
|
|
345
|
+
const tokens = userConfig.tokens || DEFAULT_TOKENS;
|
|
346
|
+
const c = { ...DEFAULT_CONTRACTS, ...(userConfig.contracts || {}) };
|
|
347
|
+
const RPC_URL = userConfig.rpcUrl || DEFAULT_RPC_URL;
|
|
348
|
+
const FEE = userConfig.v3FeeTiers || V3_FEE_TIERS;
|
|
349
|
+
const directV3Pairs = userConfig.directV3Pairs || DEFAULT_DIRECT_V3_PAIRS;
|
|
350
|
+
const platformFeeBps = BigInt(userConfig.platformFeeBps ?? DEFAULT_PLATFORM_FEE_BPS);
|
|
351
|
+
const WLD = c.wld;
|
|
352
|
+
|
|
353
|
+
const isDirectV3Pair = (a, b) =>
|
|
354
|
+
directV3Pairs.some(([x, y]) => (x === a && y === b) || (x === b && y === a));
|
|
355
|
+
|
|
356
|
+
async function POST(request) {
|
|
357
|
+
try {
|
|
358
|
+
const body = await request.json();
|
|
359
|
+
const { token_in, token_out, amount_in, token_in_address, token_out_address } = body;
|
|
360
|
+
|
|
361
|
+
if (!token_in || !token_out || !amount_in || parseFloat(amount_in) <= 0) {
|
|
362
|
+
return Response.json({ success: false, error: 'INVALID_PARAMETERS' }, { status: 400 });
|
|
363
|
+
}
|
|
364
|
+
if (!token_in_address || !token_out_address) {
|
|
365
|
+
return Response.json({ success: false, error: 'MISSING_ADDRESSES' }, { status: 400 });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let tokenInAddress, tokenOutAddress;
|
|
369
|
+
try {
|
|
370
|
+
tokenInAddress = ethers.getAddress(token_in_address);
|
|
371
|
+
tokenOutAddress = ethers.getAddress(token_out_address);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
return Response.json({ success: false, error: 'INVALID_ADDRESS' }, { status: 400 });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
|
377
|
+
const ctx = { provider, c, FEE, WLD };
|
|
378
|
+
|
|
379
|
+
const tokenInConfig = getTokenBySymbol(tokens, token_in);
|
|
380
|
+
const tokenOutConfig = getTokenBySymbol(tokens, token_out);
|
|
381
|
+
const tokenInVersion = tokenInConfig?.poolVersion || 'v2';
|
|
382
|
+
const tokenOutVersion = tokenOutConfig?.poolVersion || 'v2';
|
|
383
|
+
|
|
384
|
+
const amountInWei = ethers.parseEther(amount_in.toString());
|
|
385
|
+
const platformFeeWei = (amountInWei * platformFeeBps) / BPS_DENOMINATOR;
|
|
386
|
+
const amountAfterFeeWei = amountInWei - platformFeeWei;
|
|
387
|
+
|
|
388
|
+
let result;
|
|
389
|
+
if (isDirectV3Pair(token_in, token_out)) {
|
|
390
|
+
const cfg = tokenInConfig?.poolVersion === 'v3' ? tokenInConfig : tokenOutConfig;
|
|
391
|
+
result = await getQuoteV3(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, token_in, token_out, cfg);
|
|
392
|
+
} else if (tokenInVersion === 'v2' && tokenOutVersion === 'v2') {
|
|
393
|
+
result = await getQuoteV2(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, token_in, token_out);
|
|
394
|
+
} else if (tokenInVersion === 'v3' && tokenOutVersion === 'v3') {
|
|
395
|
+
result = await getQuoteV3(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, token_in, token_out, tokenInConfig);
|
|
396
|
+
} else if (tokenInVersion === 'v3' && tokenOutVersion === 'v2') {
|
|
397
|
+
result = await getQuoteCrossVersion(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, token_in, token_out, 'v3_to_v2', tokenInConfig);
|
|
398
|
+
} else {
|
|
399
|
+
result = await getQuoteCrossVersion(ctx, tokenInAddress, tokenOutAddress, amountAfterFeeWei, token_in, token_out, 'v2_to_v3', tokenOutConfig);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!result || !result.success) {
|
|
403
|
+
return Response.json(result || { success: false, error: 'QUOTE_FAILED' });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const amountInFormatted = parseFloat(amount_in);
|
|
407
|
+
const platformFee = parseFloat(ethers.formatEther(platformFeeWei));
|
|
408
|
+
const amountAfterFee = parseFloat(ethers.formatEther(amountAfterFeeWei));
|
|
409
|
+
const amountOut = parseFloat(ethers.formatEther(result.amountOutWei));
|
|
410
|
+
const rate = amountOut / amountAfterFee;
|
|
411
|
+
const feePercent = Number(platformFeeBps) / 100;
|
|
412
|
+
|
|
413
|
+
return Response.json({
|
|
414
|
+
success: true,
|
|
415
|
+
quote: {
|
|
416
|
+
token_in,
|
|
417
|
+
token_out,
|
|
418
|
+
amount_in: amountInFormatted,
|
|
419
|
+
platform_fee: platformFee,
|
|
420
|
+
platform_fee_percent: feePercent,
|
|
421
|
+
amount_after_fee: amountAfterFee,
|
|
422
|
+
amount_out: amountOut,
|
|
423
|
+
rate,
|
|
424
|
+
rate_display: `1 ${token_in} = ${rate.toFixed(6)} ${token_out}`,
|
|
425
|
+
price_impact: result.priceImpact || 1,
|
|
426
|
+
route_type: result.routeType,
|
|
427
|
+
pool_version: result.poolVersion,
|
|
428
|
+
path_display: result.pathDisplay,
|
|
429
|
+
path: result.path,
|
|
430
|
+
intermediate_amount: result.intermediateAmount,
|
|
431
|
+
v3_fee_tier: result.v3FeeTier,
|
|
432
|
+
expires_at: new Date(Date.now() + 30000).toISOString(),
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error('[world-swap/quote] Error:', error);
|
|
437
|
+
return Response.json({ success: false, error: 'QUOTE_FAILED', message: error.message }, { status: 500 });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function GET() {
|
|
442
|
+
return Response.json({
|
|
443
|
+
success: true,
|
|
444
|
+
supported_tokens: tokens.map((t) => ({
|
|
445
|
+
symbol: t.symbol,
|
|
446
|
+
address: t.address,
|
|
447
|
+
poolVersion: t.poolVersion || 'v2',
|
|
448
|
+
})),
|
|
449
|
+
platform_fee: `${Number(platformFeeBps) / 100}%`,
|
|
450
|
+
features: ['v2', 'v3', 'cross-version'],
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { POST, GET };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Batteries-included handler using nonnux / World Chain defaults.
|
|
458
|
+
export const { POST, GET } = createSwapQuoteHandler();
|