From 502bacb4955f9761813c7f3cfa3e951a5a9d2ba5 Mon Sep 17 00:00:00 2001 From: brite-side0 Date: Mon, 1 Jun 2026 09:09:57 +0100 Subject: [PATCH] feat: add Stellar portfolio summary command - Add portfolioService to fetch Horizon balances and price assets via DEX - Add GET /api/portfolio/:userId and GET /api/price/:assetCode endpoints - Add BOT_COMMAND_PORTFOLIO audit action - Implement !portfolio command in Discord adapter (fix dead-code closure bug) - Implement /portfolio command in Telegram adapter - Register Portfolio Summary in help provider with search keywords - Support USD, XLM, BTC display currencies with per-asset net worth --- packages/bot/src/adapters/discord.ts | 242 ++++++++++++++-------- packages/bot/src/adapters/telegram.ts | 86 +++++++- packages/bot/src/services/helpProvider.ts | 16 ++ src/AuditLog/auditLog.entity.ts | 1 + src/Gateway/routes.ts | 91 ++++++++ src/services/portfolioService.ts | 179 ++++++++++++++++ 6 files changed, 526 insertions(+), 89 deletions(-) create mode 100644 src/services/portfolioService.ts diff --git a/packages/bot/src/adapters/discord.ts b/packages/bot/src/adapters/discord.ts index ef2baed4..a8458336 100644 --- a/packages/bot/src/adapters/discord.ts +++ b/packages/bot/src/adapters/discord.ts @@ -31,7 +31,7 @@ const ADVANCED_ROLE_NAMES = (process.env.DISCORD_ADVANCED_ROLES || 'DeFi Pro,Wha const SUPPORTED_CURRENCIES = ['USD', 'XLM', 'BTC'] as const; // Commands that involve personal account data and must only be used in DMs -const DM_ONLY_COMMANDS = ['!balance', '!sponsor']; +const DM_ONLY_COMMANDS = ['!balance', '!sponsor', '!portfolio']; // Commands that start a wizard const WIZARD_COMMANDS = ['!multisig']; @@ -343,107 +343,177 @@ export class DiscordAdapter { const response = this.multisigWizard.processInput(userId, 'discord', message.content); await message.reply(response.message); } - } - // #118: !currency command — set preferred report currency - if (message.content.startsWith('!currency')) { - const arg = message.content.split(' ')[1]?.toUpperCase() as 'USD' | 'XLM' | 'BTC' | undefined; - if (!arg || !SUPPORTED_CURRENCIES.includes(arg as any)) { - return message.reply(`Usage: !currency \nCurrent: **${this.userCurrency.get(userId) ?? 'USD'}**`); + // #118: !currency command — set preferred report currency + if (message.content.startsWith('!currency')) { + const arg = message.content.split(' ')[1]?.toUpperCase() as 'USD' | 'XLM' | 'BTC' | undefined; + if (!arg || !SUPPORTED_CURRENCIES.includes(arg as any)) { + return message.reply(`Usage: !currency \nCurrent: **${this.userCurrency.get(userId) ?? 'USD'}**`); + } + this.userCurrency.set(userId, arg); + return message.reply(`✅ Report currency set to **${arg}**`); } - this.userCurrency.set(userId, arg); - return message.reply(`✅ Report currency set to **${arg}**`); - } - // #118: !report command — portfolio report in preferred currency - if (message.content.startsWith('!report')) { - const currency = this.userCurrency.get(userId) ?? 'USD'; - await message.reply(`⏳ Fetching portfolio report in **${currency}**...`); - try { - const res = await fetch(`${BACKEND_URL}/api/portfolio/${userId}?currency=${currency}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json() as { totalValue: number; assets: { code: string; balance: number; value: number }[] }; - let reply = `📊 **Portfolio Report (${currency})**\n\n`; - reply += `**Total Value:** ${data.totalValue.toFixed(4)} ${currency}\n\n`; - for (const a of data.assets) { - reply += `• **${a.code}**: ${a.balance} ≈ ${a.value.toFixed(4)} ${currency}\n`; + // #118: !report command — portfolio report in preferred currency + if (message.content.startsWith('!report')) { + const currency = this.userCurrency.get(userId) ?? 'USD'; + await message.reply(`⏳ Fetching portfolio report in **${currency}**...`); + try { + const res = await fetch(`${BACKEND_URL}/api/portfolio/${userId}?currency=${currency}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { totalValue: number; assets: { code: string; balance: number; value: number }[] }; + let reply = `📊 **Portfolio Report (${currency})**\n\n`; + reply += `**Total Value:** ${data.totalValue.toFixed(4)} ${currency}\n\n`; + for (const a of data.assets) { + reply += `• **${a.code}**: ${a.balance} ≈ ${a.value.toFixed(4)} ${currency}\n`; + } + return message.reply(reply); + } catch { + return message.reply(`❌ Could not fetch portfolio. Make sure your account is registered.`); } - return message.reply(reply); - } catch { - return message.reply(`❌ Could not fetch portfolio. Make sure your account is registered.`); } - } - // #119: !alert command — set a price alert - if (message.content.startsWith('!alert')) { - const args = message.content.split(' ').slice(1); - if (args.length < 3) { - return message.reply('Usage: !alert [USD|XLM|BTC]\nExample: !alert XLM above 0.15 USD'); - } - const [assetCode, conditionRaw, priceRaw, currencyRaw] = args; - const condition = conditionRaw.toLowerCase() as 'above' | 'below'; - if (condition !== 'above' && condition !== 'below') { - return message.reply('❌ Condition must be `above` or `below`.'); - } - const targetPrice = parseFloat(priceRaw); - if (isNaN(targetPrice) || targetPrice <= 0) { - return message.reply('❌ Price must be a positive number.'); - } - const currency = (currencyRaw?.toUpperCase() ?? this.userCurrency.get(userId) ?? 'USD') as 'USD' | 'XLM' | 'BTC'; - if (!SUPPORTED_CURRENCIES.includes(currency as any)) { - return message.reply(`❌ Currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`); + // !portfolio — formatted portfolio summary with per-asset balances and net worth + if (message.content.startsWith('!portfolio')) { + if (!isDM(message)) { + await rejectPublicChannel(message); + return; + } + await withPerformanceProfiling('!portfolio', 'discord', userId, async () => { + const args = message.content.split(' ').slice(1); + const requestedCurrency = args[0]?.toUpperCase() as 'USD' | 'XLM' | 'BTC' | undefined; + const currency = requestedCurrency ?? this.userCurrency.get(userId) ?? 'USD'; + + if (!SUPPORTED_CURRENCIES.includes(currency as any)) { + return message.reply( + `❌ Unsupported currency **${currency}**. Choose one of: ${SUPPORTED_CURRENCIES.join(', ')}\nExample: \`!portfolio USD\`` + ); + } + + await message.reply(`⏳ Fetching your Stellar portfolio in **${currency}**...`); + + try { + const res = await fetch(`${BACKEND_URL}/api/portfolio/${userId}?currency=${currency}`); + if (!res.ok) { + const err = await res.json() as { message?: string }; + throw new Error(err.message ?? `HTTP ${res.status}`); + } + + const data = await res.json() as { + address: string; + currency: string; + totalValue: number | null; + assets: { code: string; issuer: string; balance: number; value: number | null }[]; + fetchedAt: string; + }; + + const shortAddr = `\`${data.address.slice(0, 4)}...${data.address.slice(-4)}\``; + const netWorth = data.totalValue !== null + ? `**${data.totalValue.toFixed(4)} ${data.currency}**` + : '*price data unavailable*'; + + let reply = `💼 **Stellar Portfolio Summary**\n`; + reply += `📬 Account: ${shortAddr}\n`; + reply += `💰 **Net Worth:** ${netWorth}\n`; + reply += `🕐 *${new Date(data.fetchedAt).toUTCString()}*\n\n`; + reply += `**Assets**\n`; + + if (data.assets.length === 0) { + reply += '*No assets found on this account.*\n'; + } else { + for (const a of data.assets) { + const valueStr = a.value !== null + ? ` ≈ ${a.value.toFixed(4)} ${data.currency}` + : ''; + const issuerStr = a.issuer + ? ` (\`${a.issuer.slice(0, 6)}...\`)` + : ''; + reply += `• **${a.code}**${issuerStr}: ${a.balance.toFixed(7)}${valueStr}\n`; + } + } + + reply += `\n*Tip: use \`!currency \` to change your default currency.*`; + return message.reply(reply); + } catch (err) { + return message.reply( + `❌ Could not fetch portfolio: ${err instanceof Error ? err.message : String(err)}\n` + + `Make sure your account is registered — use \`!sponsor\` to get started.` + ); + } + })(); } - const alertId = `${userId}-${assetCode}-${Date.now()}`; - const alert: PriceAlert = { id: alertId, userId, assetCode: assetCode.toUpperCase(), targetPrice, currency, condition, createdAt: new Date().toISOString(), triggered: false }; - this.priceAlerts.set(alertId, alert); - // Register channel for DM delivery - if (!this.userChannels.has(userId)) this.userChannels.set(userId, message.channelId); - return message.reply(`🔔 Alert set: notify me when **${assetCode.toUpperCase()}** is ${condition} **${targetPrice} ${currency}**`); - } - // #119: !alerts — list active alerts - if (message.content === '!alerts') { - const userAlerts = [...this.priceAlerts.values()].filter(a => a.userId === userId && !a.triggered); - if (userAlerts.length === 0) return message.reply('📭 You have no active price alerts. Use `!alert` to set one.'); - let reply = `🔔 **Your Active Alerts**\n\n`; - for (const a of userAlerts) { - reply += `• **${a.assetCode}** ${a.condition} ${a.targetPrice} ${a.currency} (ID: \`${a.id.slice(-6)}\`)\n`; + // #119: !alert command — set a price alert + if (message.content.startsWith('!alert')) { + const args = message.content.split(' ').slice(1); + if (args.length < 3) { + return message.reply('Usage: !alert [USD|XLM|BTC]\nExample: !alert XLM above 0.15 USD'); + } + const [assetCode, conditionRaw, priceRaw, currencyRaw] = args; + const condition = conditionRaw.toLowerCase() as 'above' | 'below'; + if (condition !== 'above' && condition !== 'below') { + return message.reply('❌ Condition must be `above` or `below`.'); + } + const targetPrice = parseFloat(priceRaw); + if (isNaN(targetPrice) || targetPrice <= 0) { + return message.reply('❌ Price must be a positive number.'); + } + const currency = (currencyRaw?.toUpperCase() ?? this.userCurrency.get(userId) ?? 'USD') as 'USD' | 'XLM' | 'BTC'; + if (!SUPPORTED_CURRENCIES.includes(currency as any)) { + return message.reply(`❌ Currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`); + } + const alertId = `${userId}-${assetCode}-${Date.now()}`; + const alert: PriceAlert = { id: alertId, userId, assetCode: assetCode.toUpperCase(), targetPrice, currency, condition, createdAt: new Date().toISOString(), triggered: false }; + this.priceAlerts.set(alertId, alert); + // Register channel for DM delivery + if (!this.userChannels.has(userId)) this.userChannels.set(userId, message.channelId); + return message.reply(`🔔 Alert set: notify me when **${assetCode.toUpperCase()}** is ${condition} **${targetPrice} ${currency}**`); } - return message.reply(reply); - } - // #120: !advanced — role-gated command example - if (message.content.startsWith('!advanced')) { - if (!this.hasAdvancedRole(message)) { - return message.reply(`🔒 This command requires one of the following roles: **${ADVANCED_ROLE_NAMES.join(', ')}**`); + // #119: !alerts — list active alerts + if (message.content === '!alerts') { + const userAlerts = [...this.priceAlerts.values()].filter(a => a.userId === userId && !a.triggered); + if (userAlerts.length === 0) return message.reply('📭 You have no active price alerts. Use `!alert` to set one.'); + let reply = `🔔 **Your Active Alerts**\n\n`; + for (const a of userAlerts) { + reply += `• **${a.assetCode}** ${a.condition} ${a.targetPrice} ${a.currency} (ID: \`${a.id.slice(-6)}\`)\n`; + } + return message.reply(reply); } - return message.reply('✅ Advanced command executed. (Role check passed)'); - } - // #121: !discover — suggest trending Stellar assets - if (message.content === '!discover') { - if (!this.hasAdvancedRole(message)) { - return message.reply(`🔒 \`!discover\` requires one of the following roles: **${ADVANCED_ROLE_NAMES.join(', ')}**`); + // #120: !advanced — role-gated command example + if (message.content.startsWith('!advanced')) { + if (!this.hasAdvancedRole(message)) { + return message.reply(`🔒 This command requires one of the following roles: **${ADVANCED_ROLE_NAMES.join(', ')}**`); + } + return message.reply('✅ Advanced command executed. (Role check passed)'); } - await message.reply('🔍 Discovering trending Stellar assets...'); - try { - const res = await fetch(`${BACKEND_URL}/api/assets/trending`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const assets = await res.json() as TrendingAsset[]; - if (!assets.length) return message.reply('📭 No trending assets found at this time.'); - let reply = `🌟 **Trending Stellar Assets**\n\n`; - for (const a of assets.slice(0, 5)) { - const change = a.priceChange24h >= 0 ? `+${a.priceChange24h.toFixed(2)}%` : `${a.priceChange24h.toFixed(2)}%`; - const emoji = a.priceChange24h >= 0 ? '📈' : '📉'; - reply += `${emoji} **${a.assetCode}**${a.domain ? ` (${a.domain})` : ''}\n`; - reply += ` 24h Change: ${change} | Volume: ${a.volume24h.toLocaleString()} | Holders: ${a.holders.toLocaleString()}\n\n`; + + // #121: !discover — suggest trending Stellar assets + if (message.content === '!discover') { + if (!this.hasAdvancedRole(message)) { + return message.reply(`🔒 \`!discover\` requires one of the following roles: **${ADVANCED_ROLE_NAMES.join(', ')}**`); + } + await message.reply('🔍 Discovering trending Stellar assets...'); + try { + const res = await fetch(`${BACKEND_URL}/api/assets/trending`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const assets = await res.json() as TrendingAsset[]; + if (!assets.length) return message.reply('📭 No trending assets found at this time.'); + let reply = `🌟 **Trending Stellar Assets**\n\n`; + for (const a of assets.slice(0, 5)) { + const change = a.priceChange24h >= 0 ? `+${a.priceChange24h.toFixed(2)}%` : `${a.priceChange24h.toFixed(2)}%`; + const emoji = a.priceChange24h >= 0 ? '📈' : '📉'; + reply += `${emoji} **${a.assetCode}**${a.domain ? ` (${a.domain})` : ''}\n`; + reply += ` 24h Change: ${change} | Volume: ${a.volume24h.toLocaleString()} | Holders: ${a.holders.toLocaleString()}\n\n`; + } + return message.reply(reply); + } catch { + return message.reply('❌ Could not fetch trending assets. Please try again later.'); } - return message.reply(reply); - } catch { - return message.reply('❌ Could not fetch trending assets. Please try again later.'); } } - }); + )); await this.client.login(token); this.startAlertPolling(); diff --git a/packages/bot/src/adapters/telegram.ts b/packages/bot/src/adapters/telegram.ts index 85af1633..974a7ceb 100644 --- a/packages/bot/src/adapters/telegram.ts +++ b/packages/bot/src/adapters/telegram.ts @@ -7,12 +7,13 @@ import { RateLimiter, DEFAULT_RATE_LIMIT, STRICT_RATE_LIMIT } from '../rateLimit import { withPerformanceProfiling, extractCommandName } from '../performanceProfiler'; import { MultisigWizard } from '../multisigWizard'; +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000"; const DASHBOARD_URL = process.env.DASHBOARD_URL || `${process.env.API_BASE_URL || 'http://localhost:2333'}/dashboard`; const HORIZON_URL = process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'; const DEBOUNCE_MS = 1000; // 1 second debounce between commands // Commands that involve personal account data and must only be used in DMs -const DM_ONLY_COMMANDS = ['/balance']; +const DM_ONLY_COMMANDS = ['/balance', '/portfolio']; // Commands that start a wizard const WIZARD_COMMANDS = ['/multisig']; @@ -202,9 +203,87 @@ export class TelegramAdapter { })(); }); - // #125: Multisig wizard command - this.bot.command('multisig', async (ctx: any) => { + // /portfolio — formatted portfolio summary with per-asset balances and net worth + this.bot.command('portfolio', async (ctx: any) => { const userId = String(ctx.from?.id || 'unknown'); + const commandName = extractCommandName(ctx.message.text, 'telegram'); + + if (!isDM(ctx)) { + await rejectPublicChannel(ctx); + return; + } + + await withPerformanceProfiling(commandName, 'telegram', userId, async () => { + const SUPPORTED = ['USD', 'XLM', 'BTC']; + const args = ctx.message.text.split(' ').slice(1); + const currency = (args[0]?.toUpperCase() ?? 'USD'); + + if (!SUPPORTED.includes(currency)) { + return ctx.reply( + `❌ Unsupported currency ${currency}. Choose one of: ${SUPPORTED.join(', ')}\nExample: /portfolio USD`, + { parse_mode: 'HTML' } + ); + } + + await ctx.reply( + `⏳ Fetching your Stellar portfolio in ${currency}...`, + { parse_mode: 'HTML' } + ); + + try { + const res = await fetch(`${BACKEND_URL}/api/portfolio/${userId}?currency=${currency}`); + if (!res.ok) { + const err = await res.json() as { message?: string }; + throw new Error(err.message ?? `HTTP ${res.status}`); + } + + const data = await res.json() as { + address: string; + currency: string; + totalValue: number | null; + assets: { code: string; issuer: string; balance: number; value: number | null }[]; + fetchedAt: string; + }; + + const shortAddr = `${data.address.slice(0, 4)}...${data.address.slice(-4)}`; + const netWorth = data.totalValue !== null + ? `${data.totalValue.toFixed(4)} ${data.currency}` + : 'price data unavailable'; + + let reply = `💼 Stellar Portfolio Summary\n`; + reply += `📬 Account: ${shortAddr}\n`; + reply += `💰 Net Worth: ${netWorth}\n`; + reply += `🕐 ${new Date(data.fetchedAt).toUTCString()}\n\n`; + reply += `Assets\n`; + + if (data.assets.length === 0) { + reply += 'No assets found on this account.\n'; + } else { + for (const a of data.assets) { + const valueStr = a.value !== null + ? ` ≈ ${a.value.toFixed(4)} ${data.currency}` + : ''; + const issuerStr = a.issuer + ? ` (${a.issuer.slice(0, 6)}...)` + : ''; + reply += `• ${a.code}${issuerStr}: ${a.balance.toFixed(7)}${valueStr}\n`; + } + } + + reply += `\nTip: use /portfolio <USD|XLM|BTC> to choose a currency.`; + return ctx.reply(reply, { parse_mode: 'HTML' }); + } catch (err) { + return ctx.reply( + `❌ Could not fetch portfolio: ${err instanceof Error ? err.message : String(err)}\n` + + `Make sure your account is registered — use /sponsor to get started.`, + { parse_mode: 'HTML' } + ); + } + })(); + }); + + // #125: Multisig wizard command + this.bot.command('multisig', async (ctx: any) => { const userId = String(ctx.from?.id || 'unknown'); if (!isDM(ctx)) { await rejectPublicChannel(ctx); return; @@ -234,6 +313,7 @@ export class TelegramAdapter { await this.bot.telegram.setMyCommands([ { command: "start", description: "Start the bot" }, { command: "balance", description: "Check wallet balance" }, + { command: "portfolio", description: "Portfolio summary & net worth" }, { command: "swap", description: "Swap assets" }, { command: "trustline", description: "Add trustline" }, { command: "multisig", description: "Setup multisig wallet" }, diff --git a/packages/bot/src/services/helpProvider.ts b/packages/bot/src/services/helpProvider.ts index 5a484497..556fba3a 100644 --- a/packages/bot/src/services/helpProvider.ts +++ b/packages/bot/src/services/helpProvider.ts @@ -52,6 +52,22 @@ const FEATURES: BotFeature[] = [ command: "/price", keywords: ["price", "market", "cost", "value", "rate", "quote"], }, + { + name: "Portfolio Summary", + description: + "View a formatted summary of all your Stellar asset balances and estimated net worth in USD, XLM, or BTC.", + command: "/portfolio", + keywords: [ + "portfolio", + "net worth", + "summary", + "holdings", + "assets", + "total", + "wealth", + "overview", + ], + }, ]; export function searchFeatures(query: string): BotFeature[] { diff --git a/src/AuditLog/auditLog.entity.ts b/src/AuditLog/auditLog.entity.ts index 1f79f434..ea95ab77 100644 --- a/src/AuditLog/auditLog.entity.ts +++ b/src/AuditLog/auditLog.entity.ts @@ -54,6 +54,7 @@ export enum AuditAction { BOT_COMMAND_VALIDATE = "bot_command_validate", BOT_COMMAND_BALANCE = "bot_command_balance", BOT_COMMAND_SWAP = "bot_command_swap", + BOT_COMMAND_PORTFOLIO = "bot_command_portfolio", } export enum AuditSeverity { diff --git a/src/Gateway/routes.ts b/src/Gateway/routes.ts index 1bdd4704..2c5adac3 100644 --- a/src/Gateway/routes.ts +++ b/src/Gateway/routes.ts @@ -31,6 +31,7 @@ import { AuditAction, AuditSeverity } from "../AuditLog/auditLog.entity"; import { getSocketManager } from "./socketManager"; import { BotSessionService } from "../Bot/botSession.service"; import { BotSessionType, BotPlatform } from "../Bot/botSession.entity"; +import portfolioService from "../services/portfolioService"; const router = Router(); @@ -150,6 +151,8 @@ router.post("/bot/metrics", async (req: Request, res: Response) => { '/balance': AuditAction.BOT_COMMAND_BALANCE, '!swap': AuditAction.BOT_COMMAND_SWAP, '/swap': AuditAction.BOT_COMMAND_SWAP, + '!portfolio': AuditAction.BOT_COMMAND_PORTFOLIO, + '/portfolio': AuditAction.BOT_COMMAND_PORTFOLIO, }; const auditAction = commandMap[command] || AuditAction.BOT_COMMAND_START; @@ -1053,4 +1056,92 @@ router.post( } ); +// --------------------------------------------------------------------------- +// Portfolio endpoints +// --------------------------------------------------------------------------- + +/** + * GET /api/portfolio/:userId?currency=USD + * + * Returns a formatted portfolio summary for the user's Stellar account, + * including all asset balances and estimated net worth in the requested + * currency (USD | XLM | BTC, default USD). + */ +router.get( + "/portfolio/:userId", + authenticateToken, + requireOwnerOrElevated("userId"), + async (req: Request, res: Response) => { + try { + const { userId } = req.params; + const currency = (req.query.currency as string | undefined) ?? "USD"; + + const userRepository = AppDataSource.getRepository(User); + const user = await userRepository.findOne({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ success: false, message: "User not found" }); + } + + const summary = await portfolioService.getPortfolio(user.address, currency); + + await auditLogService.log({ + userId, + action: AuditAction.BOT_COMMAND_PORTFOLIO, + severity: AuditSeverity.INFO, + resource: `portfolio:${userId}`, + metadata: { currency, address: user.address }, + success: true, + }); + + return res.status(200).json({ + success: true, + address: summary.address, + currency: summary.currency, + totalValue: summary.totalValue, + assets: summary.assets.map((a) => ({ + code: a.code, + issuer: a.issuer, + balance: a.amount, + value: a.valueInCurrency, + })), + fetchedAt: summary.fetchedAt, + }); + } catch (error) { + logger.error("Portfolio fetch error", { error, userId: req.params.userId }); + const message = error instanceof Error ? error.message : "Internal server error"; + const statusCode = + message.includes("not found") || message.includes("unreachable") ? 404 : 500; + return res.status(statusCode).json({ success: false, message }); + } + } +); + +/** + * GET /api/price/:assetCode?currency=USD + * + * Returns the current DEX price of an asset in the requested currency. + * Used by the bot's price-alert polling loop and the !portfolio command. + */ +router.get("/price/:assetCode", async (req: Request, res: Response) => { + try { + const { assetCode } = req.params; + const currency = (req.query.currency as string | undefined) ?? "USD"; + + const result = await portfolioService.getAssetPrice(assetCode, currency); + + return res.status(200).json({ + success: true, + assetCode: result.assetCode, + currency: result.currency, + price: result.price, + }); + } catch (error) { + logger.error("Price fetch error", { error, assetCode: req.params.assetCode }); + return res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : "Internal server error", + }); + } +}); + export default router; diff --git a/src/services/portfolioService.ts b/src/services/portfolioService.ts new file mode 100644 index 00000000..7dbd7b1e --- /dev/null +++ b/src/services/portfolioService.ts @@ -0,0 +1,179 @@ +/** + * Portfolio Service + * + * Fetches a user's Stellar account balances from Horizon and calculates + * estimated net worth by pricing each asset against a target currency + * via the Stellar DEX. + */ + +import * as StellarSdk from "@stellar/stellar-sdk"; +import config from "../config/config"; +import logger from "../config/logger"; +import stellarPriceService from "./stellarPrice.service"; + +export interface AssetBalance { + /** Asset code, e.g. "XLM", "USDC" */ + code: string; + /** Issuer public key — empty string for native XLM */ + issuer: string; + /** Raw balance string from Horizon */ + balance: string; + /** Numeric balance */ + amount: number; + /** Estimated value in the requested currency (null if price unavailable) */ + valueInCurrency: number | null; + /** Whether this is the native XLM asset */ + isNative: boolean; +} + +export interface PortfolioSummary { + /** Stellar account address */ + address: string; + /** Currency used for net-worth calculation */ + currency: string; + /** All asset balances on the account */ + assets: AssetBalance[]; + /** + * Sum of all asset values in the requested currency. + * null when no prices could be resolved at all. + */ + totalValue: number | null; + /** ISO timestamp of when this snapshot was taken */ + fetchedAt: string; +} + +const SUPPORTED_CURRENCIES = ["USD", "XLM", "BTC"] as const; +type SupportedCurrency = (typeof SUPPORTED_CURRENCIES)[number]; + +// Assets the price service knows how to look up (see stellarPrice.service.ts) +const PRICEABLE_ASSETS = new Set(["XLM", "USDC", "USDT"]); + +export class PortfolioService { + private server: StellarSdk.Horizon.Server; + + constructor() { + this.server = new StellarSdk.Horizon.Server(config.stellar.horizonUrl); + } + + /** + * Fetch all balances for a Stellar account and price them in the given + * currency (USD | XLM | BTC, default USD). + */ + async getPortfolio( + address: string, + currency: string = "USD" + ): Promise { + const normalizedCurrency = currency.toUpperCase() as SupportedCurrency; + + if (!SUPPORTED_CURRENCIES.includes(normalizedCurrency)) { + throw new Error( + `Unsupported currency "${currency}". Supported: ${SUPPORTED_CURRENCIES.join(", ")}` + ); + } + + // Fetch account from Horizon + let account: StellarSdk.Horizon.AccountResponse; + try { + account = await this.server.accounts().accountId(address).call(); + } catch (err) { + logger.error("PortfolioService: failed to load account", { address, err }); + throw new Error(`Account not found or Horizon unreachable for: ${address}`); + } + + const rawBalances = + account.balances as StellarSdk.Horizon.HorizonApi.BalanceLine[]; + + // Build the asset list + const assets: AssetBalance[] = rawBalances.map((b) => { + const isNative = b.asset_type === "native"; + const code = isNative + ? "XLM" + : (b as StellarSdk.Horizon.HorizonApi.BalanceLineAsset).asset_code; + const issuer = isNative + ? "" + : (b as StellarSdk.Horizon.HorizonApi.BalanceLineAsset).asset_issuer; + + return { + code, + issuer, + balance: b.balance, + amount: parseFloat(b.balance), + valueInCurrency: null, + isNative, + }; + }); + + // Price each asset concurrently + await Promise.all( + assets.map(async (asset) => { + // Zero-balance assets are worth zero regardless of price + if (asset.amount === 0) { + asset.valueInCurrency = 0; + return; + } + + // Asset IS the target currency — 1:1 + if (asset.code === normalizedCurrency) { + asset.valueInCurrency = asset.amount; + return; + } + + // Only attempt DEX pricing for assets the price service supports + if (!PRICEABLE_ASSETS.has(asset.code)) { + return; // leave as null + } + + try { + const quote = await stellarPriceService.getPrice( + asset.code, + normalizedCurrency, + asset.amount + ); + asset.valueInCurrency = quote.estimatedOutput; + } catch (err) { + logger.warn( + `PortfolioService: could not price ${asset.code} → ${normalizedCurrency}`, + { err } + ); + // leave as null — partial data is still useful + } + }) + ); + + // Total = sum of assets where a price was resolved + const pricedAssets = assets.filter((a) => a.valueInCurrency !== null); + const totalValue = + pricedAssets.length > 0 + ? pricedAssets.reduce((sum, a) => sum + (a.valueInCurrency ?? 0), 0) + : null; + + return { + address, + currency: normalizedCurrency, + assets, + totalValue, + fetchedAt: new Date().toISOString(), + }; + } + + /** + * Get the current DEX price of a single asset in the given currency. + */ + async getAssetPrice( + assetCode: string, + currency: string = "USD" + ): Promise<{ assetCode: string; currency: string; price: number }> { + const from = assetCode.toUpperCase(); + const to = currency.toUpperCase(); + + if (from === to) { + return { assetCode: from, currency: to, price: 1 }; + } + + const quote = await stellarPriceService.getPrice(from, to, 1); + return { assetCode: from, currency: to, price: quote.price }; + } +} + +export const portfolioService = new PortfolioService(); +export default portfolioService;