Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 50 additions & 37 deletions packages/bot/src/adapters/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1064,22 +1064,23 @@ export class DiscordAdapter {
if (!arg || !(SUPPORTED_CURRENCIES as readonly string[]).includes(arg)) {
return message.reply(`Usage: !currency <USD|XLM|BTC>\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
Expand Down Expand Up @@ -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 <assetCode> <above|below> <price> [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") {
Expand Down
79 changes: 79 additions & 0 deletions packages/bot/src/adapters/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b>${currency}</b>. Choose one of: ${SUPPORTED.join(', ')}\nExample: <code>/portfolio USD</code>`,
{ parse_mode: 'HTML' }
);
}

await ctx.reply(
`⏳ Fetching your Stellar portfolio in <b>${currency}</b>...`,
{ 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 = `<code>${data.address.slice(0, 4)}...${data.address.slice(-4)}</code>`;
const netWorth = data.totalValue !== null
? `<b>${data.totalValue.toFixed(4)} ${data.currency}</b>`
: '<i>price data unavailable</i>';

let reply = `💼 <b>Stellar Portfolio Summary</b>\n`;
reply += `📬 Account: ${shortAddr}\n`;
reply += `💰 <b>Net Worth:</b> ${netWorth}\n`;
reply += `🕐 <i>${new Date(data.fetchedAt).toUTCString()}</i>\n\n`;
reply += `<b>Assets</b>\n`;

if (data.assets.length === 0) {
reply += '<i>No assets found on this account.</i>\n';
} else {
for (const a of data.assets) {
const valueStr = a.value !== null
? ` ≈ ${a.value.toFixed(4)} ${data.currency}`
: '';
const issuerStr = a.issuer
? ` (<code>${a.issuer.slice(0, 6)}...</code>)`
: '';
reply += `• <b>${a.code}</b>${issuerStr}: ${a.balance.toFixed(7)}${valueStr}\n`;
}
}

reply += `\n<i>Tip: use /portfolio &lt;USD|XLM|BTC&gt; to choose a currency.</i>`;
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;
Expand Down Expand Up @@ -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" },
Expand Down
16 changes: 16 additions & 0 deletions packages/bot/src/services/helpProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
1 change: 1 addition & 0 deletions src/AuditLog/auditLog.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions src/Gateway/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading