|
@@ -75,24 +75,31 @@ export class JupiterSwapper {
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Fetch quote from Jupiter Metis API with retry logic
|
|
* Fetch quote from Jupiter Metis API with retry logic
|
|
|
|
|
+ * amount: raw amount (lamports/atomic units); must be string for API (ExactIn=input, ExactOut=output)
|
|
|
|
|
+ * restrictIntermediate: if true, only use liquid intermediate tokens; if false, allow more routes (use when NO_ROUTES_FOUND)
|
|
|
*/
|
|
*/
|
|
|
- async fetchQuote(inputMint, outputMint, amount, retries = 3, swapMode = 'ExactIn') {
|
|
|
|
|
|
|
+ async fetchQuote(inputMint, outputMint, amount, retries = 3, swapMode = 'ExactIn', restrictIntermediate = true) {
|
|
|
|
|
+ const rawAmount = typeof amount === 'string' ? amount : String(Math.floor(Number(amount)));
|
|
|
|
|
+ if (!rawAmount || Number(rawAmount) <= 0) {
|
|
|
|
|
+ throw new Error(`Invalid quote amount: ${amount}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
|
try {
|
|
try {
|
|
|
logger.info(`Fetching quote from Jupiter Metis API (attempt ${attempt}/${retries})...`);
|
|
logger.info(`Fetching quote from Jupiter Metis API (attempt ${attempt}/${retries})...`);
|
|
|
- logger.info(` Mode: ${swapMode}, Amount: ${amount}`);
|
|
|
|
|
|
|
+ logger.info(` Mode: ${swapMode}, Amount (raw): ${rawAmount}, restrictIntermediate: ${restrictIntermediate}`);
|
|
|
|
|
|
|
|
const response = await axios.get(
|
|
const response = await axios.get(
|
|
|
`${this.jupiterBaseUrl}/quote`,
|
|
`${this.jupiterBaseUrl}/quote`,
|
|
|
{
|
|
{
|
|
|
params: {
|
|
params: {
|
|
|
- inputMint: inputMint,
|
|
|
|
|
- outputMint: outputMint,
|
|
|
|
|
- amount: amount,
|
|
|
|
|
- swapMode: swapMode,
|
|
|
|
|
|
|
+ inputMint,
|
|
|
|
|
+ outputMint,
|
|
|
|
|
+ amount: rawAmount,
|
|
|
|
|
+ swapMode,
|
|
|
slippageBps: CONFIG.SLIPPAGE_BPS,
|
|
slippageBps: CONFIG.SLIPPAGE_BPS,
|
|
|
onlyDirectRoutes: false,
|
|
onlyDirectRoutes: false,
|
|
|
- restrictIntermediateTokens: true,
|
|
|
|
|
|
|
+ restrictIntermediateTokens: restrictIntermediate,
|
|
|
},
|
|
},
|
|
|
timeout: 30000,
|
|
timeout: 30000,
|
|
|
headers: this.getHeaders(),
|
|
headers: this.getHeaders(),
|
|
@@ -105,14 +112,21 @@ export class JupiterSwapper {
|
|
|
}
|
|
}
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
const status = error.response?.status;
|
|
const status = error.response?.status;
|
|
|
- const message = error.response?.data?.message || error.message;
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const data = error.response?.data;
|
|
|
|
|
+ const message = data?.message || data?.error || error.message;
|
|
|
|
|
+ const fullBody = data ? JSON.stringify(data) : '';
|
|
|
|
|
+
|
|
|
if (status === 401) {
|
|
if (status === 401) {
|
|
|
logger.error('Jupiter API authentication failed. Please check your API key at https://portal.jup.ag');
|
|
logger.error('Jupiter API authentication failed. Please check your API key at https://portal.jup.ag');
|
|
|
throw new Error('Jupiter API Key invalid or missing');
|
|
throw new Error('Jupiter API Key invalid or missing');
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- logger.warn(`Quote attempt ${attempt} failed: ${message}`);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if (status === 400) {
|
|
|
|
|
+ logger.warn(`Quote 400 Bad Request: ${message}`);
|
|
|
|
|
+ if (fullBody) logger.warn(`Response: ${fullBody}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ logger.warn(`Quote attempt ${attempt} failed: ${message}`);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
if (attempt < retries) {
|
|
if (attempt < retries) {
|
|
|
await sleep(2000 * attempt);
|
|
await sleep(2000 * attempt);
|
|
@@ -173,19 +187,20 @@ export class JupiterSwapper {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async swapIfNeeded(inputMint, outputMint, requiredAmount, decimals = 6) {
|
|
|
|
|
- const currentBalance = await this.getTokenBalance(outputMint);
|
|
|
|
|
-
|
|
|
|
|
- if (currentBalance >= requiredAmount) {
|
|
|
|
|
- logger.success(`Sufficient balance: ${currentBalance.toFixed(4)} >= ${requiredAmount.toFixed(4)}`);
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Swap a fixed USD amount of USDC into the output token (ExactIn).
|
|
|
|
|
+ * Uses valueUsd from calculation API - no ExactOut, no token amount.
|
|
|
|
|
+ * @param {string} outputMint - Token mint to receive
|
|
|
|
|
+ * @param {number} usdValue - USD value to swap (e.g. 5.5 = $5.5 USDC in)
|
|
|
|
|
+ * @returns {Promise<boolean>}
|
|
|
|
|
+ */
|
|
|
|
|
+ async swapIfNeeded(outputMint, usdValue) {
|
|
|
|
|
+ const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
|
|
|
+ if (!usdValue || Number(usdValue) <= 0) {
|
|
|
|
|
+ logger.info(`Skip swap: USD value is ${usdValue}`);
|
|
|
return true;
|
|
return true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const neededAmount = requiredAmount - currentBalance;
|
|
|
|
|
- logger.warn(`Insufficient balance. Need ${neededAmount.toFixed(4)} more.`);
|
|
|
|
|
- logger.info(`Initiating swap from ${inputMint} to ${outputMint}...`);
|
|
|
|
|
-
|
|
|
|
|
- // Check if API key is configured
|
|
|
|
|
if (!this.apiKey) {
|
|
if (!this.apiKey) {
|
|
|
logger.error('═══════════════════════════════════════════════════');
|
|
logger.error('═══════════════════════════════════════════════════');
|
|
|
logger.error('Jupiter API Key is required!');
|
|
logger.error('Jupiter API Key is required!');
|
|
@@ -195,58 +210,62 @@ export class JupiterSwapper {
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const inputAmountRaw = Math.floor(Number(usdValue) * 1e6);
|
|
|
|
|
+ if (inputAmountRaw < 1e6) {
|
|
|
|
|
+ logger.warn(`Skip swap: USD value too small (${usdValue})`);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(`Swap: $${usdValue} USDC -> ${outputMint.slice(0, 8)}... (ExactIn)`);
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
- // Use ExactOut mode: we need exactly `neededAmount` of the output token
|
|
|
|
|
- // Convert to raw amount with correct decimals (from API)
|
|
|
|
|
- const outputAmount = Math.ceil(neededAmount * Math.pow(10, decimals));
|
|
|
|
|
-
|
|
|
|
|
- logger.info(`Swap: USDC -> ${outputMint.slice(0, 8)}...`);
|
|
|
|
|
- logger.info(`Required output: ${neededAmount} (decimals: ${decimals}, raw: ${outputAmount})`);
|
|
|
|
|
-
|
|
|
|
|
- // Fetch quote with ExactOut swap mode
|
|
|
|
|
- const quoteData = await this.fetchQuote(
|
|
|
|
|
- inputMint,
|
|
|
|
|
- outputMint,
|
|
|
|
|
- outputAmount,
|
|
|
|
|
- 3,
|
|
|
|
|
- 'ExactOut'
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const tryQuote = async (restrictIntermediate = true) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await this.fetchQuote(
|
|
|
|
|
+ USDC_MINT,
|
|
|
|
|
+ outputMint,
|
|
|
|
|
+ inputAmountRaw,
|
|
|
|
|
+ 2,
|
|
|
|
|
+ 'ExactIn',
|
|
|
|
|
+ restrictIntermediate
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e.message && e.message.includes('No routes found') && restrictIntermediate) {
|
|
|
|
|
+ logger.warn('No routes with restrictIntermediate=true, retrying with allow all intermediates...');
|
|
|
|
|
+ return await this.fetchQuote(USDC_MINT, outputMint, inputAmountRaw, 2, 'ExactIn', false);
|
|
|
|
|
+ }
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const quoteData = await tryQuote();
|
|
|
|
|
|
|
|
- // Log route info
|
|
|
|
|
if (quoteData.routePlan && quoteData.routePlan.length > 0) {
|
|
if (quoteData.routePlan && quoteData.routePlan.length > 0) {
|
|
|
const routeLabels = quoteData.routePlan.map(r => r.swapInfo?.label || 'Unknown').join(' -> ');
|
|
const routeLabels = quoteData.routePlan.map(r => r.swapInfo?.label || 'Unknown').join(' -> ');
|
|
|
logger.info(`Route: ${routeLabels}`);
|
|
logger.info(`Route: ${routeLabels}`);
|
|
|
- logger.info(`Expected output: ${quoteData.outAmount} (${quoteData.swapMode})`);
|
|
|
|
|
- logger.info(`Price impact: ${quoteData.priceImpactPct}%`);
|
|
|
|
|
|
|
+ logger.info(`Expected output: ${quoteData.outAmount} (ExactIn), price impact: ${quoteData.priceImpactPct}%`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Execute swap with retry
|
|
|
|
|
const swapData = await this.executeSwap(quoteData);
|
|
const swapData = await this.executeSwap(quoteData);
|
|
|
-
|
|
|
|
|
const swapTransactionBuf = Buffer.from(swapData.swapTransaction, 'base64');
|
|
const swapTransactionBuf = Buffer.from(swapData.swapTransaction, 'base64');
|
|
|
const transaction = VersionedTransaction.deserialize(swapTransactionBuf);
|
|
const transaction = VersionedTransaction.deserialize(swapTransactionBuf);
|
|
|
-
|
|
|
|
|
transaction.sign([this.keypair]);
|
|
transaction.sign([this.keypair]);
|
|
|
|
|
|
|
|
const signature = await this.connection.sendTransaction(transaction, {
|
|
const signature = await this.connection.sendTransaction(transaction, {
|
|
|
maxRetries: 3,
|
|
maxRetries: 3,
|
|
|
skipPreflight: false,
|
|
skipPreflight: false,
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
logger.info(`Swap transaction sent: ${signature}`);
|
|
logger.info(`Swap transaction sent: ${signature}`);
|
|
|
|
|
|
|
|
const confirmation = await this.connection.confirmTransaction(signature, 'confirmed');
|
|
const confirmation = await this.connection.confirmTransaction(signature, 'confirmed');
|
|
|
-
|
|
|
|
|
if (confirmation.value.err) {
|
|
if (confirmation.value.err) {
|
|
|
throw new Error(`Transaction failed: ${confirmation.value.err}`);
|
|
throw new Error(`Transaction failed: ${confirmation.value.err}`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
logger.success(`Swap confirmed: https://solscan.io/tx/${signature}`);
|
|
logger.success(`Swap confirmed: https://solscan.io/tx/${signature}`);
|
|
|
-
|
|
|
|
|
const newBalance = await this.getTokenBalance(outputMint);
|
|
const newBalance = await this.getTokenBalance(outputMint);
|
|
|
logger.info(`New balance: ${newBalance.toFixed(4)}`);
|
|
logger.info(`New balance: ${newBalance.toFixed(4)}`);
|
|
|
-
|
|
|
|
|
- return newBalance >= requiredAmount;
|
|
|
|
|
|
|
+ return true;
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
logger.error('Swap failed:', error.message);
|
|
logger.error('Swap failed:', error.message);
|
|
|
return false;
|
|
return false;
|