Created
January 20, 2025 20:52
-
-
Save jongan69/78f734c793552a1a6bf0a77e22094e09 to your computer and use it in GitHub Desktop.
Using Metaplex UMI for token data, reference lockin repo tokenUtils to see original
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AddressLookupTableAccount, Connection, PublicKey, PublicKeyInitData, TransactionInstruction } from "@solana/web3.js"; | |
// import { Metaplex } from "@metaplex-foundation/js"; | |
import Bottleneck from "bottleneck"; | |
import { | |
fetchDigitalAssetWithAssociatedToken, | |
mplTokenMetadata, | |
findMetadataPda} from '@metaplex-foundation/mpl-token-metadata' | |
import { fromWeb3JsPublicKey, toWeb3JsPublicKey } from '@metaplex-foundation/umi-web3js-adapters' | |
import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' | |
import { fetchIpfsMetadata } from "./fetchIpfsMetadata"; | |
import { extractCidFromUrl } from "./extractCidFromUrl"; | |
import { fetchJupiterSwap } from "./fetchJupiterSwap"; | |
import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; | |
import { LOCKIN_MINT, TOKEN_PROGRAM_ID_ADDRESS } from "@utils/globals"; | |
import { Instruction } from "@jup-ag/api"; | |
import { fetchFloorPrice } from "./fetchFloorPrice"; | |
import { NETWORK } from "@utils/endpoints"; | |
import { createJupiterApiClient, QuoteGetRequest } from "@jup-ag/api"; | |
import { getTokenInfo } from "./fetchDexTokenInfo"; | |
const ENDPOINT = NETWORK; | |
// Add validation for the endpoint URL | |
if (!ENDPOINT || (!ENDPOINT.startsWith('http:') && !ENDPOINT.startsWith('https:'))) { | |
// console.log(`ENDPOINT: ${ENDPOINT}`); | |
throw new Error('Invalid RPC endpoint URL. Must start with http: or https:'); | |
} | |
// console.log(`ENDPOINT: ${ENDPOINT}`); | |
const connection = new Connection(ENDPOINT); | |
// const metaplex = Metaplex.make(connection); | |
const DEFAULT_IMAGE_URL = process.env.UNKNOWN_IMAGE_URL || "https://s3.coinmarketcap.com/static-gravity/image/5cc0b99a8dd84fbfa4e150d84b5531f2.png"; | |
// Modify the rate limiters at the top | |
const rpcLimiter = new Bottleneck({ | |
maxConcurrent: 5, // Reduce concurrent requests | |
minTime: 200, // Increase delay between requests | |
reservoir: 30, // Initial tokens | |
reservoirRefreshAmount: 30, // Tokens to refresh | |
reservoirRefreshInterval: 1000, // Refresh every second | |
retryCount: 3, // Number of retries | |
retryDelay: 1000, // Delay between retries | |
}); | |
export const apiLimiter = new Bottleneck({ | |
maxConcurrent: 3, // Reduce concurrent requests | |
minTime: 333, // About 3 requests per second | |
reservoir: 20, // Initial tokens | |
reservoirRefreshAmount: 20, // Tokens to refresh | |
reservoirRefreshInterval: 1000, // Refresh every second | |
retryCount: 3, | |
retryDelay: 1000, | |
}); | |
// Add retry wrapper function | |
const withRetry = async <T>(operation: () => Promise<T>, maxRetries: number = 3, delayMs: number = 1000): Promise<T | null> => { | |
let lastError; | |
for (let i = 0; i < maxRetries; i++) { | |
try { | |
return await operation(); | |
} catch (error: any) { | |
lastError = error; | |
if (error.toString().includes('rate limit') || error.toString().includes('429')) { | |
console.log(`Rate limit hit, attempt ${i + 1}/${maxRetries}, waiting ${delayMs}ms...`); | |
await new Promise(resolve => setTimeout(resolve, delayMs * (i + 1))); // Exponential backoff | |
continue; | |
} | |
console.warn("Error fetching data:", error); | |
} | |
} | |
console.warn("Last error:", lastError); | |
return null; | |
}; | |
// Add these constants at the top | |
const MAX_TOKEN_FETCH_RETRIES = 2; | |
const TOKEN_FETCH_RETRY_DELAY = 1000; | |
// Add a retry wrapper function | |
const withTokenRetry = async <T>( | |
operation: () => Promise<T>, | |
tokenIdentifier: string | |
): Promise<T | null> => { | |
let attempts = 0; | |
while (attempts < MAX_TOKEN_FETCH_RETRIES) { | |
try { | |
return await operation(); | |
} catch (error) { | |
attempts++; | |
console.error(`Attempt ${attempts} failed for token ${tokenIdentifier}:`, error); | |
if (attempts < MAX_TOKEN_FETCH_RETRIES) { | |
await new Promise(resolve => setTimeout(resolve, TOKEN_FETCH_RETRY_DELAY)); | |
continue; | |
} | |
console.log(`Skipping token ${tokenIdentifier} after ${attempts} failed attempts`); | |
return null; | |
} | |
} | |
return null; | |
}; | |
export type TokenData = { | |
decimals: number; | |
mintAddress: string; | |
tokenAddress: string; | |
name?: string; | |
amount: number; | |
symbol?: string; | |
logo?: string; | |
cid?: string | null; | |
usdValue: number; | |
collectionName?: string; | |
collectionLogo?: string; | |
isNft?: boolean; | |
swappable: boolean; | |
}; | |
export async function fetchTokenMetadata(mintAddress: PublicKey, mint: string, amount: number) { | |
try { | |
// console.log("π Starting fetchTokenMetadata for mint:", mint); | |
const umi = createUmi(ENDPOINT).use(mplTokenMetadata()); | |
// console.log("π₯ Fetching digital asset with associated token..."); | |
const asset = await fetchDigitalAssetWithAssociatedToken( | |
umi, | |
fromWeb3JsPublicKey(mintAddress), | |
fromWeb3JsPublicKey(mintAddress) | |
); | |
if (!asset) { | |
// console.log("β οΈ Token account not found, returning default metadata"); | |
return getDefaultTokenMetadata(mint, amount); | |
} | |
const cid = extractCidFromUrl(asset.metadata.uri); | |
const IPFSMetadata = await apiLimiter.schedule(() => | |
fetchIpfsMetadata(cid as string) | |
); | |
console.log(asset.metadata.collection.__option) | |
// Extract metadata from the digital asset | |
let metadata = { | |
name: asset.metadata.name || mint, | |
symbol: asset.metadata.symbol || mint, | |
logo: IPFSMetadata.imageUrl ?? DEFAULT_IMAGE_URL, | |
cid: extractCidFromUrl(asset.metadata.uri), | |
collectionName: asset.metadata.name || mint, | |
collectionLogo: IPFSMetadata.imageUrl ?? DEFAULT_IMAGE_URL, | |
isNft: asset.metadata.collection.__option === "None" ? false : true | |
}; | |
// Handle collection metadata | |
if (asset.metadata.collection && 'key' in asset.metadata.collection) { | |
try { | |
const collectionKey = fromWeb3JsPublicKey(new PublicKey(asset.metadata.collection.key as string)); | |
const collectionMetadata = await fetchCollectionMetadata(new PublicKey(collectionKey.toString())); | |
metadata = { | |
...metadata, | |
collectionName: collectionMetadata?.name ?? metadata.name, | |
collectionLogo: collectionMetadata?.logo ?? metadata.logo, | |
isNft: true | |
}; | |
} catch (collectionError) { | |
console.warn(`Failed to fetch collection metadata for token ${mint}:`, collectionError); | |
} | |
} | |
return metadata; | |
} catch (error) { | |
console.warn("β Error in fetchTokenMetadata:", error); | |
return getDefaultTokenMetadata(mint, amount); | |
} | |
} | |
async function fetchCollectionMetadata(collectionAddress: PublicKey) { | |
// console.log("π Starting fetchCollectionMetadata for:", collectionAddress.toString()); | |
try { | |
const umi = createUmi(ENDPOINT).use(mplTokenMetadata()); | |
// console.log("π₯ Fetching collection asset..."); | |
const collectionAsset = await fetchDigitalAssetWithAssociatedToken( | |
umi, | |
fromWeb3JsPublicKey(collectionAddress), | |
fromWeb3JsPublicKey(collectionAddress) | |
); | |
const metadataPda = findMetadataPda(umi, { mint: fromWeb3JsPublicKey(collectionAddress) }); | |
const metadataAccountInfo = await withRetry(() => | |
rpcLimiter.schedule(() => | |
connection.getAccountInfo(toWeb3JsPublicKey(metadataPda[0])) | |
) | |
); | |
if (!metadataAccountInfo) { | |
// console.log(`No metadata account found for collection: ${collectionAddress.toString()}`); | |
return getDefaultMetadata(); | |
} | |
const cid = extractCidFromUrl(collectionAsset.metadata.uri); | |
if (cid) { | |
try { | |
const collectionMetadata = await apiLimiter.schedule(() => | |
fetchIpfsMetadata(cid) | |
); | |
return { | |
name: collectionAsset.metadata.name || "Unknown Collection", | |
symbol: collectionAsset.metadata.symbol || "UNKNOWN", | |
logo: collectionMetadata.imageUrl ?? collectionAsset.metadata.uri ?? DEFAULT_IMAGE_URL, | |
cid: cid, | |
isNft: true | |
}; | |
} catch (ipfsError) { | |
console.warn(`Failed to fetch IPFS metadata for collection ${collectionAddress.toString()}:`, ipfsError); | |
return { | |
name: collectionAsset.metadata.name || "Unknown Collection", | |
symbol: collectionAsset.metadata.symbol || "UNKNOWN", | |
logo: collectionAsset.metadata.uri ?? DEFAULT_IMAGE_URL, | |
cid: cid, | |
isNft: true | |
}; | |
} | |
} | |
return { | |
name: collectionAsset.metadata.name || "Unknown Collection", | |
symbol: collectionAsset.metadata.symbol || "UNKNOWN", | |
logo: collectionAsset.metadata.uri ?? DEFAULT_IMAGE_URL, | |
cid: null, | |
isNft: true | |
}; | |
} catch (error) { | |
console.error("β Error in fetchCollectionMetadata:", error); | |
return getDefaultMetadata(); | |
} | |
} | |
// Add a helper function to return default metadata | |
function getDefaultMetadata() { | |
// console.log("Returning default metadata"); | |
return { | |
name: "Unknown Collection", | |
symbol: "UNKNOWN", | |
logo: DEFAULT_IMAGE_URL, | |
cid: null, | |
isNft: true | |
}; | |
} | |
// Helper function to get default token metadata | |
async function getDefaultTokenMetadata(mint: string, amount: number) { | |
// console.log("Returning default token metadata for:", mint); | |
const tokenInfo = await getTokenInfo(mint); | |
// console.log("DexScreener Token info:", tokenInfo); | |
const usdValue = (tokenInfo?.price * amount) ?? 0; | |
return { | |
name: tokenInfo?.name || mint, | |
symbol: tokenInfo?.symbol || mint, | |
usdValue, | |
logo: tokenInfo?.image || DEFAULT_IMAGE_URL, | |
cid: null, | |
collectionName: tokenInfo?.name || mint, | |
collectionLogo: tokenInfo?.image || DEFAULT_IMAGE_URL, | |
isNft: false | |
}; | |
} | |
// Fetch token accounts | |
export async function fetchTokenAccounts(publicKey: PublicKey) { | |
// console.log("Fetching token accounts for:", publicKey.toString()); | |
return withRetry(() => | |
rpcLimiter.schedule(() => | |
connection.getParsedTokenAccountsByOwner(publicKey, { | |
programId: TOKEN_PROGRAM_ID_ADDRESS, | |
}) | |
) | |
); | |
} | |
// Fetch token data | |
export async function handleTokenData(publicKey: PublicKey, tokenAccount: any): Promise<TokenData | null> { | |
const mintAddress = tokenAccount.account.data.parsed.info.mint; | |
const amount = tokenAccount.account.data.parsed.info.tokenAmount.uiAmount || 0; | |
const decimals = tokenAccount.account.data.parsed.info.tokenAmount.decimals; | |
const [tokenAccountAddress] = PublicKey.findProgramAddressSync( | |
[publicKey.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mintAddress).toBuffer()], | |
ASSOCIATED_TOKEN_PROGRAM_ID | |
); | |
// console.log("π₯ Fetching Jupiter price..."); | |
let jupiterPrice = null; | |
try { | |
const jupiterResponse = await withTokenRetry( | |
() => apiLimiter.schedule(() => fetchJupiterSwap(mintAddress)), | |
mintAddress | |
); | |
if (jupiterResponse?.skipped) { | |
console.log(`Skipping price fetch for token ${mintAddress}: ${jupiterResponse.error}`); | |
} else if (jupiterResponse?.error) { | |
console.warn(`Jupiter price fetch error for ${mintAddress}:`, jupiterResponse.error); | |
} else if (jupiterResponse?.data) { | |
jupiterPrice = jupiterResponse.data; | |
console.log("π° Jupiter price data:", jupiterPrice); | |
} | |
} catch (error) { | |
console.warn(`Unable to fetch Jupiter price for token ${mintAddress}:`, error); | |
} | |
// console.log("π₯ Fetching token metadata..."); | |
const metadata = await withTokenRetry( | |
() => fetchTokenMetadata(new PublicKey(mintAddress), mintAddress, amount), | |
mintAddress | |
); | |
console.log("π Token metadata:", metadata); | |
if (!metadata) { | |
console.log(`Skipping token ${mintAddress} due to metadata fetch failure`); | |
return null; | |
} | |
let price = 0; | |
let priceSource = 'none'; | |
if (metadata.isNft) { | |
try { | |
const floorPrice = await withTokenRetry( | |
() => apiLimiter.schedule(() => fetchFloorPrice(mintAddress)), | |
mintAddress | |
); | |
price = floorPrice?.usdValue || 0; | |
priceSource = price > 0 ? 'floor' : 'none'; | |
console.log(`${metadata.collectionName} NFT Floor price: $${price}`); | |
} catch (error) { | |
console.warn(`Unable to fetch floor price for NFT ${mintAddress}:`, error); | |
} | |
} else { | |
try { | |
const tokenPrice = jupiterPrice?.[mintAddress]?.price; | |
if (tokenPrice) { | |
price = tokenPrice; | |
priceSource = 'jupiter'; | |
} else { | |
price = 0; | |
priceSource = 'none'; | |
console.log(`No valid price found for token ${mintAddress}: ${jupiterPrice?.[mintAddress]?.price}`); | |
} | |
} catch (error) { | |
console.warn(`Error accessing Jupiter price data for ${mintAddress}:`, error); | |
price = 0; | |
priceSource = 'none'; | |
} | |
} | |
const usdValue = amount * price; | |
console.log(`Token ${metadata.name} (${mintAddress}) - Price: $${price} (${priceSource}), Total Value: $${usdValue}`); | |
// Check if token is swappable with retry | |
let isSwappable = false; | |
try { | |
isSwappable = await withTokenRetry( | |
() => isTokenSwappable( | |
mintAddress, | |
LOCKIN_MINT, | |
tokenAccount.account.data.parsed.info.tokenAmount.amount | |
), | |
mintAddress | |
) ?? false; | |
} catch (error) { | |
console.warn(`Error checking if token ${mintAddress} is swappable:`, error); | |
} | |
return { | |
mintAddress, | |
tokenAddress: tokenAccountAddress.toString(), | |
amount, | |
decimals, | |
usdValue, | |
...metadata, | |
swappable: isSwappable, | |
}; | |
} | |
export const deserializeInstruction = (instruction: Instruction) => { | |
return new TransactionInstruction({ | |
programId: new PublicKey(instruction.programId), | |
keys: instruction.accounts.map((key: { pubkey: PublicKeyInitData; isSigner: any; isWritable: any; }) => ({ | |
pubkey: new PublicKey(key.pubkey), | |
isSigner: key.isSigner, | |
isWritable: key.isWritable, | |
})), | |
data: Buffer.from(instruction.data, "base64"), | |
}); | |
}; | |
export const getAddressLookupTableAccounts = async (connection: Connection, keys: any[]) => { | |
const addressLookupTableAccountInfos = await connection.getMultipleAccountsInfo( | |
keys.map((key) => new PublicKey(key)) | |
); | |
return addressLookupTableAccountInfos.reduce<AddressLookupTableAccount[]>((acc, accountInfo, index) => { | |
const addressLookupTableAddress = keys[index]; | |
if (accountInfo) { | |
const addressLookupTableAccount = new AddressLookupTableAccount({ | |
key: new PublicKey(addressLookupTableAddress), | |
state: AddressLookupTableAccount.deserialize(accountInfo.data as any), | |
}); | |
if (typeof addressLookupTableAccount !== "undefined") { | |
acc.push(addressLookupTableAccount); | |
} | |
} | |
return acc; | |
}, []); | |
}; | |
export async function isTokenSwappable(inputMint: string, targetMint: string, amount: number): Promise<boolean> { | |
if (inputMint === LOCKIN_MINT) { | |
// console.log("β Token is LOCKIN_MINT, automatically swappable"); | |
return true; | |
} | |
if (amount === 0) { | |
// console.log("β οΈ Amount is 0, skipping swappable check"); | |
return false; | |
} | |
try { | |
// console.log("π Checking if token is swappable:", { | |
// inputMint, | |
// targetMint, | |
// amount | |
// }); | |
const jupiterQuoteApi = createJupiterApiClient(); | |
return await withRetry(async () => { | |
// console.log("π₯ Fetching Jupiter quote..."); | |
const params: QuoteGetRequest = { | |
inputMint, | |
outputMint: targetMint, | |
amount, | |
slippageBps: 50, | |
onlyDirectRoutes: false, | |
}; | |
const quote = await jupiterQuoteApi.quoteGet(params) as any; | |
return quote?.routes?.length > 0; | |
}) ?? false; | |
} catch (error) { | |
console.error("β Error checking if token is swappable:", error); | |
return false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment