Last active
February 14, 2025 03:49
-
-
Save nshen/3ed70e81917a2e5bd2328033335fca8d to your computer and use it in GitHub Desktop.
get-wallet-details.ts
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
/* | |
tsx --env-file=.env ./get-wallet-details.ts | |
接口2: 输入钱包地址,返回钱包的详情 | |
每笔买入交易得到钱包余额,钱包地址,买入金额,买入单价,买入数量,交易签名,代币地址,代币名称, | |
卖出交易得到钱包余额,钱包地址,卖出金额,卖出单价,卖出数量,交易签名,代币地址,代币名称(最好能算出总价值)) | |
使用方法 | |
try { | |
// const res = await getWalletDetails('4wBbK41H6Pe9E7DxMefLz9Y7ge2TJAn5RSYmwb1vB9jU') | |
const res = await getWalletDetails('J6e9tmh36UmzXHjXoyRLBpWMgF1QgsDU6FvGWH8jADxb') | |
console.log(res); | |
} catch (error) { | |
console.error(error.toString()); | |
} | |
*/ | |
import { Connection, ParsedTransactionWithMeta, PublicKey } from "@solana/web3.js"; | |
interface PumpFunTrade { | |
type: 'buy' | 'sell'; | |
// 代币地址 | |
tokenAddress: string; | |
// 代币名称 | |
tokenName: string; | |
// 交易后钱包余额 | |
walletBalance: string; | |
// 买入金额 (总花费包含了各种费) | |
solAmount: string; | |
// 买入或卖出数量 | |
tokenAmount: string; | |
// 买入或卖出单价 | |
tokenPrice: string; | |
// 交易签名 | |
signature: string; | |
// 距离第一次买入的时间 | |
timestamp: number; | |
} | |
interface TokenMetrics { | |
// 币地址 | |
tokenAddress: string; | |
// 币名称 | |
tokenName: string; | |
// 买入单个币的数量 | |
tokenBuyAmount: number; | |
// 平均买入单个币的价格 | |
tokenBuyPrice: number; | |
// 平均卖出单个币的价格 | |
tokenSellPrice: number; | |
// 单个币的数量(买入的和卖出的币数量里面较少的数,如卖出10个币,买入20个,这个值为10) | |
tokenAmount: number; | |
// 单个币的利润(如果tokenAmount为0,这个值也为0,计算公式是(tokenSellPrice-tokenBuyPrice)* tokenAmount) | |
tokenProfit: number; | |
// 单个币的利润率(如果tokenAmount为0,这个值也为0,计算公式是(tokenSellPrice - tokenBuyPrice) / tokenBuyPrice) | |
tokenProfitRate: number; | |
}; | |
interface TokenBalanceChange { | |
mint: string; | |
owner: string; | |
preAmount: number; | |
postAmount: number; | |
decimals: number; | |
} | |
interface TradeEventType { | |
mint: string; | |
solAmount: bigint; | |
tokenAmount: bigint; | |
isBuy: boolean; | |
user: string; | |
timestamp: bigint; | |
virtualSolReserves: bigint; | |
virtualTokenReserves: bigint; | |
} | |
async function batchFetchData(signatures: string[], batchSize: number = 20) { | |
const allData: ParsedTransactionWithMeta[] = []; | |
for (let i = 0; i < signatures.length; i += batchSize) { | |
const batch = signatures.slice(i, i + batchSize); | |
const batchResults = (await connection.getParsedTransactions(batch, { maxSupportedTransactionVersion: 0 })) | |
.filter((result): result is ParsedTransactionWithMeta => result !== null); | |
allData.push(...batchResults); | |
console.log(`Completed fetched ${i / batchSize + 1}. Total items: ${allData.length}`); | |
} | |
return allData; | |
} | |
const PUMP_PROGRAM = new PublicKey("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"); | |
const connection = new Connection(process.env.HTTP_PROVIDER as string) | |
export function isPumpFunTransaction(tx: ParsedTransactionWithMeta): boolean { | |
if (!tx.meta) return false | |
if (!tx.transaction.message) return false; | |
const hasProgramCall = tx.transaction.message.instructions | |
.some(ix => ix.programId.toBase58() === PUMP_PROGRAM.toBase58()); | |
// 检查日志特征 | |
const hasLogPattern = tx.meta.logMessages?.some(msg => | |
msg.includes('Program log: Instruction: Buy') || | |
msg.includes('Program log: Instruction: Sell') | |
// || msg.includes('Program log: Instruction: CreateToken') | |
) || false; | |
return hasProgramCall && hasLogPattern; | |
} | |
function parseTradeEvent(buffer: Buffer): TradeEventType | null { | |
// TradeEvent 结构: | |
// mint: publicKey (32 bytes) | |
// solAmount: u64 (8 bytes) | |
// tokenAmount: u64 (8 bytes) | |
// isBuy: bool (1 byte) | |
// user: publicKey (32 bytes) | |
// timestamp: i64 (8 bytes) | |
// virtualSolReserves: u64 (8 bytes) | |
// virtualTokenReserves: u64 (8 bytes) | |
if (buffer.length < 8) { | |
return null | |
} | |
let offset = 8; | |
const mint = new PublicKey(buffer.slice(offset, offset + 32)).toString(); | |
offset += 32; | |
const solAmount = buffer.readBigUInt64LE(offset); | |
offset += 8; | |
const tokenAmount = buffer.readBigUInt64LE(offset); | |
offset += 8; | |
const isBuy = buffer.readUInt8(offset) === 1; | |
offset += 1; | |
const user = new PublicKey(buffer.slice(offset, offset + 32)).toString(); | |
offset += 32; | |
const timestamp = buffer.readBigInt64LE(offset); | |
offset += 8; | |
const virtualSolReserves = buffer.readBigUInt64LE(offset); | |
offset += 8 | |
const virtualTokenReserves = buffer.readBigUInt64LE(offset); | |
return { | |
mint, | |
solAmount, | |
tokenAmount, | |
isBuy, | |
user, | |
timestamp, | |
virtualSolReserves, | |
virtualTokenReserves | |
} | |
} | |
export async function parsePumpFunTx(tx: ParsedTransactionWithMeta): Promise<PumpFunTrade | null> { | |
if (tx.meta == null) { | |
console.log('meta is null'); | |
return null; | |
} | |
if (!isPumpFunTransaction(tx)) | |
return null; | |
try { | |
const signature = tx.transaction.signatures[0]; | |
// const walletAddress = tx.transaction.message.accountKeys[0].pubkey.toBase58(); | |
const fee = tx.meta.fee / 1e9; | |
const dataLog = tx.meta.logMessages?.find(log => log.startsWith('Program data:')); | |
if (!dataLog) { | |
// console.log('dataLog is null', tx.meta.logMessages); | |
return null; | |
} | |
const base64Data = dataLog.split('Program data: ')[1]; | |
const decodedData = Buffer.from(base64Data, 'base64'); | |
const event = parseTradeEvent(decodedData) | |
if (!event) { | |
console.log('event is null', decodedData); | |
return null; | |
} | |
const solReserves = Number(event.virtualSolReserves) / 1e9; | |
const tokenReserves = Number(event.virtualTokenReserves) / 1e6; | |
const unitPrice = (solReserves / tokenReserves).toFixed(10); | |
const isBuy = event.isBuy; | |
// 代币余额变化分析 | |
const tokenChanges = tx.meta.postTokenBalances?.map((post, i: number) => { | |
const pre = tx.meta?.preTokenBalances?.[i]; | |
return { | |
mint: post.mint, | |
owner: post.owner, | |
preAmount: pre?.uiTokenAmount.uiAmount, | |
postAmount: post.uiTokenAmount.uiAmount, | |
decimals: post.uiTokenAmount.decimals | |
} as TokenBalanceChange; | |
}); | |
// SOL 余额变化 | |
const solBalanceChange = (tx.meta.preBalances[0] - tx.meta.postBalances[0]) / 1e9 // - fee; | |
// 找到主代币交易对 | |
const mainTokenChange = tokenChanges?.find(t => t.mint === event.mint); | |
// 计算需要指标 | |
if (mainTokenChange) { | |
// const tokenDelta = mainTokenChange.postAmount - mainTokenChange.preAmount; | |
const solDelta = Math.abs(solBalanceChange); | |
// const unitPrice = solDelta / tokenDeltaAbs; | |
// 取 metadata | |
const tokenInfo = await getTokenInfo(mainTokenChange.mint); | |
return { | |
type: isBuy ? 'buy' : 'sell', | |
walletBalance: (tx.meta.postBalances[0] / 1e9).toFixed(10), | |
// walletAddress, | |
solAmount: solDelta.toFixed(10), | |
tokenPrice: unitPrice, | |
tokenAmount: (Number(event.tokenAmount) / 1e6).toFixed(10), //tokenDeltaAbs.toFixed(10), | |
signature, | |
tokenAddress: mainTokenChange.mint, | |
tokenName: tokenInfo.symbol, | |
timestamp: Number(event.timestamp) * 1000, | |
}; | |
} | |
return null; | |
} catch (error) { | |
console.error('解析失败:', error); | |
return null; | |
} | |
} | |
const _symbolCache = {} | |
async function getTokenInfo(mint: string): Promise<{ symbol: string, supply: number }> { | |
if (_symbolCache[mint]) return _symbolCache[mint]; | |
const provider = process.env.HTTP_PROVIDER as string | |
const key = provider.split('api-key=')[1]; | |
const response = await fetch('https://mainnet.helius-rpc.com/?api-key=' + key, { | |
method: 'POST', | |
headers: { | |
"Content-Type": "application/json" | |
}, | |
body: JSON.stringify({ | |
"jsonrpc": "2.0", | |
"id": "test", | |
"method": "getAsset", | |
"params": { | |
"id": mint | |
} | |
}), | |
}); | |
const data = await response.json(); | |
if (data?.result?.token_info) { | |
if (!data.result.token_info.symbol) | |
data.result.token_info.symbol = 'UNKNOWN'; | |
// cache | |
_symbolCache[mint] = data.result.token_info; | |
return _symbolCache[mint]; | |
} | |
return { | |
symbol: 'UNKNOWN', | |
supply: 0, | |
} | |
} | |
async function getTrades(connection: Connection, publicKey: PublicKey, limit: number): Promise<PumpFunTrade[]> { | |
const signaturesInfo = await connection.getSignaturesForAddress( | |
publicKey, | |
{ | |
limit, | |
} | |
); | |
const signatures = signaturesInfo.map(s => s.signature); | |
const txs: ParsedTransactionWithMeta[] = await batchFetchData(signatures, 20); | |
const pumpTxs = txs.filter(isPumpFunTransaction); | |
const pumpTrads = await Promise.all(pumpTxs.map(async (tx) => await parsePumpFunTx(tx))); | |
return pumpTrads.filter((r): r is PumpFunTrade => r !== null); | |
} | |
function timeSince(date: Date): string { | |
const seconds: number = Math.floor((new Date().getTime() - date.getTime()) / 1000); | |
let interval: number = seconds / 31536000; // 年 | |
if (interval > 1) | |
return Math.floor(interval) + " 年前"; | |
interval = seconds / 2592000; // 月 | |
if (interval > 1) | |
return Math.floor(interval) + " 个月前"; | |
interval = seconds / 86400; // 天 | |
if (interval > 1) | |
return Math.floor(interval) + " 天前"; | |
interval = seconds / 3600; // 小时 | |
if (interval > 1) | |
return Math.floor(interval) + " 小时前"; | |
interval = seconds / 60; // 分钟 | |
if (interval > 1) | |
return Math.floor(interval) + " 分钟前"; | |
return Math.floor(seconds) + " 秒前"; | |
} | |
function calculateROI(trades: PumpFunTrade[]): TokenMetrics[] { | |
const tokenMap = new Map<string, { | |
buyTotal: number; | |
buyQty: number; | |
sellTotal: number; | |
sellQty: number; | |
symbol: string; | |
}>(); | |
// 第一步:按代币分组统计 | |
for (const trade of trades) { | |
const address = trade.tokenAddress; | |
const price = parseFloat(trade.tokenPrice); | |
const amount = parseFloat(trade.tokenAmount); | |
if (!tokenMap.has(address)) { | |
tokenMap.set(address, { | |
buyTotal: 0, | |
buyQty: 0, | |
sellTotal: 0, | |
sellQty: 0, | |
symbol: trade.tokenName | |
}); | |
} | |
const data = tokenMap.get(address)!; | |
if (trade.type === 'buy') { | |
data.buyTotal += price * amount; | |
data.buyQty += amount; | |
} else { | |
data.sellTotal += price * amount; | |
data.sellQty += amount; | |
} | |
} | |
// 第二步:关键数据 | |
return Array.from(tokenMap.entries()).map(([address, data]) => { | |
const avgBuyPrice = data.buyQty > 0 ? data.buyTotal / data.buyQty : 0; | |
const avgSellPrice = data.sellQty > 0 ? data.sellTotal / data.sellQty : 0; | |
const matchedAmount = Math.min(data.buyQty, data.sellQty); | |
const profit = matchedAmount > 0 | |
? (avgSellPrice - avgBuyPrice) * matchedAmount | |
: 0; | |
const profitRate = matchedAmount > 0 && avgBuyPrice > 0 | |
? ((avgSellPrice - avgBuyPrice) / avgBuyPrice) : 0; | |
return { | |
tokenAddress: address, | |
tokenName: data.symbol, | |
tokenBuyAmount: data.buyQty, | |
tokenBuyPrice: avgBuyPrice, | |
tokenSellPrice: avgSellPrice, | |
tokenAmount: matchedAmount, | |
tokenProfit: profit, | |
tokenProfitRate: profitRate | |
}; | |
}).filter(t => (t.tokenSellPrice > 0 && t.tokenBuyPrice > 0)); | |
; | |
} | |
function getLatestBuyTime(trades: PumpFunTrade[]): number { | |
const buyTrade = trades.find(trade => trade.type === "buy"); | |
return buyTrade ? new Date(Number(buyTrade.timestamp) * 1000).getTime() : 0; | |
} | |
// 需求 | |
// a = 求和((tokenBuyPrice * tokenAmount)) / 求和 (tokenAmount) | |
// b =(tokenSellPrice * tokenAmount)/ tokenAmount | |
// totalProfitRate = (b - a)/a | |
function calculateTotalProfitRateBoss(roi: TokenMetrics[]): number { | |
let totalCost = 0; | |
let totalRevenue = 0; | |
let totalAmount = 0; | |
roi.forEach(metric => { | |
totalCost += metric.tokenBuyPrice * metric.tokenAmount; | |
totalRevenue += metric.tokenSellPrice * metric.tokenAmount; | |
totalAmount += metric.tokenAmount; | |
}); | |
const avgBuyPrice = totalCost / totalAmount; // a | |
const avgSellPrice = totalRevenue / totalAmount; // b | |
return (avgSellPrice - avgBuyPrice) / avgBuyPrice; // (b - a) / a | |
} | |
export async function getWalletDetails(walletAddress: string, limit: number = 1000) { | |
// 检查地址长度 | |
if (walletAddress.length !== 44) | |
throw "❌ 地址无效:长度必须为 44 个字符"; | |
// 检查 Base58 编码合法性 | |
if (!/^[1-9A-HJ-NP-Za-km-z]{44}$/.test(walletAddress)) | |
throw "❌ 地址无效:包含非法字符"; | |
const publicKey = new PublicKey(walletAddress); | |
const accountInfo = await connection.getAccountInfo(publicKey, { commitment: "confirmed" }); | |
if (!accountInfo) | |
throw "⚠️ 空账户/不存在"; | |
if (accountInfo.owner.toBase58() !== "11111111111111111111111111111111") | |
throw "❌ 非用户钱包地址"; | |
const trades = await getTrades(connection, publicKey, limit); | |
const roi = calculateROI(trades); | |
const topProfit = Math.max(...roi.map(t => t.tokenProfit)); | |
const topProfitRate = Math.max(...roi.map(t => t.tokenProfitRate)); | |
const totalProfit = roi.reduce((acc, t) => acc + t.tokenProfit, 0); | |
const totalProfitRate = calculateTotalProfitRateBoss(roi); | |
return { | |
walletAddress, | |
walletBalance: accountInfo.lamports / 1e9, | |
timestamp: new Date().getTime() - getLatestBuyTime(trades), | |
totalProfit, // 总利润(单位SOL) | |
totalProfitRate, // 总利润率 | |
topProfit, //最高利润(单位SOL)(某个代币买入卖出实现最高盈利多少SOL) | |
topProfitRate, // 最高利润率(某个代币买入卖出实现最高利润多少) | |
trades, | |
roi | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment