diff --git a/packages/bot/src/adapters/discord.ts b/packages/bot/src/adapters/discord.ts index d2e003c1..0eb88dae 100644 --- a/packages/bot/src/adapters/discord.ts +++ b/packages/bot/src/adapters/discord.ts @@ -1064,22 +1064,23 @@ export class DiscordAdapter { if (!arg || !(SUPPORTED_CURRENCIES as readonly string[]).includes(arg)) { return message.reply(`Usage: !currency \nCurrent: **${this.userCurrency.get(userId) ?? 'USD'}**`); } - 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.`); } // #120: !advanced — role-gated command example @@ -1112,32 +1113,44 @@ export class DiscordAdapter { if (!(SUPPORTED_CURRENCIES as readonly string[]).includes(currency)) { 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}**`); - } - // #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") { diff --git a/packages/bot/src/adapters/telegram.ts b/packages/bot/src/adapters/telegram.ts index 512a50ab..1947e801 100644 --- a/packages/bot/src/adapters/telegram.ts +++ b/packages/bot/src/adapters/telegram.ts @@ -530,6 +530,84 @@ export class TelegramAdapter { const userId = String(ctx.from?.id || "unknown"); this.bot.command('multisig', async (ctx: Context) => { 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; @@ -712,6 +790,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 8aaaab29..2a99c78d 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 abfc78dd..c3876456 100644 --- a/src/AuditLog/auditLog.entity.ts +++ b/src/AuditLog/auditLog.entity.ts @@ -73,6 +73,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 c356f0dc..5711fc0e 100644 --- a/src/Gateway/routes.ts +++ b/src/Gateway/routes.ts @@ -1098,4 +1098,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;