diff --git a/packages/bot/src/adapters/discord.js b/packages/bot/src/adapters/discord.js index 6c9ec140..c88886f2 100644 --- a/packages/bot/src/adapters/discord.js +++ b/packages/bot/src/adapters/discord.js @@ -14,23 +14,33 @@ const discord_js_1 = require("discord.js"); const sdk_core_1 = require("@chen-pilot/sdk-core"); const helpProvider_1 = require("../services/helpProvider"); const assetVerification_1 = require("../assetVerification"); +const rateLimiter_1 = require("../rateLimiter"); const performanceProfiler_1 = require("../performanceProfiler"); +const multisigWizard_1 = require("../multisigWizard"); +const scamDetection_1 = require("../scamDetection"); +const marketOverview_1 = require("../marketOverview"); const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000"; const DASHBOARD_URL = process.env.DASHBOARD_URL || `${BACKEND_URL}/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', '!sponsor']; -function isDM(message) { - return message.channel.type === discord_js_1.ChannelType.DM; -} -function rejectPublicChannel(message) { - return __awaiter(this, void 0, void 0, function* () { - yield message.reply('๐Ÿ”’ This command contains sensitive account data and can only be used in a Direct Message (DM) with the bot.'); - }); -} +const DEBOUNCE_MS = 2000; +// Role names required for advanced commands (#120) +const ADVANCED_ROLE_NAMES = (process.env.DISCORD_ADVANCED_ROLES || 'DeFi Pro,Whale,Admin').split(',').map(r => r.trim()); +// Supported currencies for reports (#118) +const SUPPORTED_CURRENCIES = ['USD', 'XLM', 'BTC']; // Commands that involve personal account data and must only be used in DMs const DM_ONLY_COMMANDS = ['!balance', '!sponsor']; +// Commands that start a wizard +const WIZARD_COMMANDS = ['!multisig']; +// Commands that require stricter rate limiting +const SENSITIVE_COMMANDS = ['!sponsor', '!trustline', '!validate']; +// #124: Scam detection configuration +const SCAM_DETECTION_ENABLED = process.env.DISCORD_SCAM_DETECTION_ENABLED !== 'false'; +const SCAM_DETECTION_ACTION = (process.env.DISCORD_SCAM_DETECTION_ACTION || 'flag'); +const SCAM_DETECTION_CHANNELS = (process.env.DISCORD_SCAM_DETECTION_CHANNELS || '').split(',').filter(c => c.trim()); +// #128: Daily market overview digest configuration +const MARKET_OVERVIEW_ENABLED = process.env.DISCORD_MARKET_OVERVIEW_ENABLED === 'true'; +const MARKET_OVERVIEW_CHANNEL_ID = process.env.DISCORD_MARKET_OVERVIEW_CHANNEL_ID || ''; +const MARKET_OVERVIEW_TIME = process.env.DISCORD_MARKET_OVERVIEW_TIME || '09:00'; // Format: HH:MM in UTC function isDM(message) { return message.channel.type === discord_js_1.ChannelType.DM; } @@ -44,6 +54,10 @@ class DiscordAdapter { this.userChannels = new Map(); // userId -> channelId // #145: Track last command timestamp per user this.lastCommandTime = new Map(); + // #118: User preferred currency (userId -> currency) + this.userCurrency = new Map(); + // #119: Active price alerts + this.priceAlerts = new Map(); this.token = token; this.auditLogChannelId = auditLogChannelId || process.env.DISCORD_AUDIT_LOG_CHANNEL_ID; this.client = new discord_js_1.Client({ @@ -51,9 +65,19 @@ class DiscordAdapter { discord_js_1.GatewayIntentBits.Guilds, discord_js_1.GatewayIntentBits.GuildMessages, discord_js_1.GatewayIntentBits.MessageContent, + discord_js_1.GatewayIntentBits.GuildMembers, ], }); this.verificationService = new assetVerification_1.AssetVerificationService(HORIZON_URL); + // #125: Initialize multisig wizard + this.multisigWizard = new multisigWizard_1.MultisigWizard(); + // #123: Initialize rate limiters + this.defaultRateLimiter = new rateLimiter_1.RateLimiter(rateLimiter_1.DEFAULT_RATE_LIMIT); + this.strictRateLimiter = new rateLimiter_1.RateLimiter(rateLimiter_1.STRICT_RATE_LIMIT); + // #124: Initialize scam detection service + this.scamDetectionService = new scamDetection_1.ScamDetectionService(); + // #128: Initialize market overview service + this.marketOverviewService = new marketOverview_1.MarketOverviewService(); } // #145: Returns true if the user is flooding (within debounce window) isFlooding(userId) { @@ -65,6 +89,128 @@ class DiscordAdapter { this.lastCommandTime.set(userId, now); return false; } + // #123: Check rate limit for a user and command + checkRateLimit(userId, command) { + // Determine which rate limiter to use based on command + const isSensitive = SENSITIVE_COMMANDS.some(cmd => command.startsWith(cmd)); + const rateLimiter = isSensitive ? this.strictRateLimiter : this.defaultRateLimiter; + const status = rateLimiter.check(userId); + if (!status.allowed) { + const retryAfter = status.retryAfter || 60; + return { + allowed: false, + message: `โณ Rate limit exceeded. Please wait ${retryAfter} seconds before trying again.` + }; + } + return { allowed: true }; + } + // #124: Check if scam detection should be applied to a channel + shouldScanForScams(message) { + if (!SCAM_DETECTION_ENABLED) + return false; + if (isDM(message)) + return false; // Don't scan DMs + // If specific channels are configured, only scan those + if (SCAM_DETECTION_CHANNELS.length > 0) { + return SCAM_DETECTION_CHANNELS.includes(message.channelId); + } + // Otherwise, scan all public channels + return true; + } + // #124: Handle detected scam links + handleScamDetection(message, result) { + return __awaiter(this, void 0, void 0, function* () { + const warningMessage = `๐Ÿšจ **Potential Scam Link Detected**\n\n` + + `**Reason:** ${result.reason}\n` + + `**Pattern:** \`${result.matchedPattern}\`\n\n` + + `This message has been ${SCAM_DETECTION_ACTION === 'block' ? 'blocked' : 'flagged'} for your safety.`; + if (SCAM_DETECTION_ACTION === 'block') { + yield message.delete(); + // Cast to TextChannel since we only scan public channels + if (message.channel.type === discord_js_1.ChannelType.GuildText || message.channel.type === discord_js_1.ChannelType.GuildPublicThread || message.channel.type === discord_js_1.ChannelType.GuildPrivateThread) { + yield message.channel.send(warningMessage); + } + } + else { + yield message.reply(warningMessage); + } + // Log to audit channel if configured + yield this.logAuditAction({ + action: 'SCAM_LINK_DETECTED', + triggeredBy: message.author.id, + details: `Reason: ${result.reason}, Pattern: ${result.matchedPattern}, Action: ${SCAM_DETECTION_ACTION}`, + success: true, + timestamp: new Date().toISOString(), + }); + }); + } + // #128: Calculate milliseconds until next scheduled market overview post + getTimeUntilNextSchedule() { + const [hours, minutes] = MARKET_OVERVIEW_TIME.split(':').map(Number); + const now = new Date(); + const scheduledTime = new Date(); + scheduledTime.setUTCHours(hours, minutes, 0, 0); + // If the scheduled time has already passed today, schedule for tomorrow + if (scheduledTime <= now) { + scheduledTime.setDate(scheduledTime.getDate() + 1); + } + return scheduledTime.getTime() - now.getTime(); + } + // #128: Post daily market overview to configured channel + postMarketOverview() { + return __awaiter(this, void 0, void 0, function* () { + if (!MARKET_OVERVIEW_CHANNEL_ID) { + console.warn('โš ๏ธ Market overview channel ID not configured, skipping digest'); + return; + } + try { + console.log('๐Ÿ“Š Fetching daily market overview...'); + const marketData = yield this.marketOverviewService.fetchMarketOverview(); + const message = this.marketOverviewService.formatMarketOverviewMessage(marketData); + const channel = this.client.channels.cache.get(MARKET_OVERVIEW_CHANNEL_ID); + if (!channel) { + console.error(`โŒ Market overview channel ${MARKET_OVERVIEW_CHANNEL_ID} not found`); + return; + } + yield channel.send(message); + console.log('โœ… Daily market overview posted successfully'); + yield this.logAuditAction({ + action: 'MARKET_OVERVIEW_POSTED', + triggeredBy: 'system', + details: `Channel: ${MARKET_OVERVIEW_CHANNEL_ID}`, + success: true, + timestamp: new Date().toISOString(), + }); + } + catch (error) { + console.error('โŒ Error posting market overview:', error); + yield this.logAuditAction({ + action: 'MARKET_OVERVIEW_FAILED', + triggeredBy: 'system', + details: `Error: ${error instanceof Error ? error.message : String(error)}`, + success: false, + timestamp: new Date().toISOString(), + }); + } + }); + } + // #128: Start the daily market overview scheduler + startMarketOverviewScheduler() { + if (!MARKET_OVERVIEW_ENABLED || !MARKET_OVERVIEW_CHANNEL_ID) { + console.log('โ„น๏ธ Market overview digest disabled or not configured'); + return; + } + const initialDelay = this.getTimeUntilNextSchedule(); + console.log(`๐Ÿ“… Market overview digest scheduled for ${MARKET_OVERVIEW_TIME} UTC (next post in ${Math.round(initialDelay / 1000 / 60)} minutes)`); + // Schedule the first post + setTimeout(() => __awaiter(this, void 0, void 0, function* () { + yield this.postMarketOverview(); + // Then schedule daily posts (24 hours = 86400000 ms) + this.marketOverviewInterval = setInterval(() => __awaiter(this, void 0, void 0, function* () { + yield this.postMarketOverview(); + }), 24 * 60 * 60 * 1000); + }), initialDelay); + } init() { return __awaiter(this, void 0, void 0, function* () { const token = process.env.DISCORD_BOT_TOKEN || this.token; @@ -76,141 +222,380 @@ class DiscordAdapter { var _a; console.log(`โœ… Discord bot logged in as ${(_a = this.client.user) === null || _a === void 0 ? void 0 : _a.tag}`); this.startStatusUpdates(); + // #117: Automated welcome flow for new server members + this.client.on('guildMemberAdd', (member) => __awaiter(this, void 0, void 0, function* () { + try { + yield this.sendWelcomeMessage(member); + } + catch (error) { + console.error('โŒ Error sending welcome message:', error); + } + })); }); this.client.on("messageCreate", (0, performanceProfiler_1.withPerformanceProfiling)('messageCreate', 'discord', 'system', (message) => __awaiter(this, void 0, void 0, function* () { + var _a, _b, _c, _d, _e, _f; if (message.author.bot) return; + // #124: Scan for scam links in public channels + if (this.shouldScanForScams(message)) { + const scamResult = this.scamDetectionService.detectScamLinks(message.content); + if (scamResult.isScam) { + yield this.handleScamDetection(message, scamResult); + return; // Stop processing if scam is detected and blocked + } + } const userId = message.author.id; + const command = message.content.split(' ')[0]; + const commandName = (0, performanceProfiler_1.extractCommandName)(message.content, 'discord'); // #145: Anti-flood check for all commands if (this.isFlooding(userId)) { yield message.reply("โณ Please wait a moment before sending another command."); return; } + // #123: Rate limit check + const rateLimitResult = this.checkRateLimit(userId, command); + if (!rateLimitResult.allowed) { + yield message.reply((_a = rateLimitResult.message) !== null && _a !== void 0 ? _a : 'โณ Rate limit exceeded. Please try again later.'); + return; + } + // Wrap each command handler with performance profiling if (message.content === "!start") { - yield message.reply("Welcome to Chen Pilot! I am your AI-powered Stellar DeFi assistant. Type !help to see what I can do!"); + yield (0, performanceProfiler_1.withPerformanceProfiling)('!start', 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + yield message.reply("Welcome to Chen Pilot! I am your AI-powered Stellar DeFi assistant. Type !help to see what I can do!"); + }))(); + } + // #134: Ping command โ€” measure end-to-end latency + if (message.content === '!ping') { + yield (0, performanceProfiler_1.withPerformanceProfiling)('!ping', 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + const startTime = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = yield fetch(`${BACKEND_URL}/api/health`, { + method: 'GET', + signal: controller.signal, + }); + clearTimeout(timeout); + const roundtripMs = Date.now() - startTime; + if (response.ok) { + yield message.reply(`๐Ÿ“ **Pong!**\n\n๐Ÿ“ก **End-to-End Latency:** ${roundtripMs}ms\nโœ… Backend: Online`); + } + else { + yield message.reply(`๐Ÿ“ **Pong!**\n\n๐Ÿ“ก **End-to-End Latency:** ${roundtripMs}ms\nโš ๏ธ Backend: Returned HTTP ${response.status}`); + } + } + catch (_a) { + const roundtripMs = Date.now() - startTime; + yield message.reply(`๐Ÿ“ **Pong!**\n\n๐Ÿ“ก **End-to-End Latency:** ${roundtripMs}ms\nโŒ Backend: Unreachable`); + } + }))(); } if (message.content.startsWith("!help")) { - const query = message.content.replace("!help", "").trim(); - const results = (0, helpProvider_1.searchFeatures)(query); - const isSearch = query.length > 0; - yield message.reply((0, helpProvider_1.formatHelpMessage)(results, isSearch, "markdown")); + yield (0, performanceProfiler_1.withPerformanceProfiling)(commandName, 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + const query = message.content.replace("!help", "").trim(); + const results = (0, helpProvider_1.searchFeatures)(query); + const isSearch = query.length > 0; + yield message.reply((0, helpProvider_1.formatHelpMessage)(results, isSearch, "markdown")); + }))(); } if (message.content === "!thread") { - if (message.channel.type === discord_js_1.ChannelType.GuildText) { + yield (0, performanceProfiler_1.withPerformanceProfiling)('!thread', 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + if (message.channel.type === discord_js_1.ChannelType.GuildText) { + try { + const thread = yield message.startThread({ + name: `Chen Pilot Session - ${message.author.username}`, + autoArchiveDuration: 60, + }); + yield thread.send(`๐Ÿ‘‹ Hello ${message.author.username}! I've started this thread to keep our conversation organized. How can I help you with Stellar DeFi today?`); + } + catch (error) { + console.error("Error creating thread:", error); + yield message.reply("โŒ I couldn't start a thread. Please make sure I have the 'Create Public Threads' permission."); + } + } + else if (message.channel.isThread()) { + yield message.reply("๐Ÿงต We are already in a thread! I'm ready to assist you here."); + } + else { + yield message.reply("โŒ Threads can only be started in text channels."); + } + }))(); + } + if (message.content === "!sponsor") { + yield (0, performanceProfiler_1.withPerformanceProfiling)('!sponsor', 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + yield message.reply("โณ Requesting account sponsorship..."); try { - const thread = yield message.startThread({ - name: `Chen Pilot Session - ${message.author.username}`, - autoArchiveDuration: 60, + const response = yield fetch(`${BACKEND_URL}/api/account/${userId}/sponsor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, }); - yield thread.send(`๐Ÿ‘‹ Hello ${message.author.username}! I've started this thread to keep our conversation organized. How can I help you with Stellar DeFi today?`); + const data = (yield response.json()); + if (data.success) { + yield message.reply(`โœ… Account sponsored successfully!\n๐Ÿ“ฌ Address: \`${data.address}\``); + yield this.logAuditAction({ + action: 'SPONSOR_ACCOUNT', + triggeredBy: userId, + details: `Address: ${data.address}`, + success: true, + timestamp: new Date().toISOString(), + }); + } + else { + yield message.reply(`โŒ Sponsorship failed: ${data.message}`); + yield this.logAuditAction({ + action: 'SPONSOR_ACCOUNT', + triggeredBy: userId, + details: `Failed: ${data.message}`, + success: false, + timestamp: new Date().toISOString(), + }); + } } catch (error) { - console.error("Error creating thread:", error); - yield message.reply("โŒ I couldn't start a thread. Please make sure I have the 'Create Public Threads' permission."); + console.error("Sponsor command error:", error); + yield message.reply("โŒ Could not reach the sponsorship service. Please try again later."); } - } - else if (message.channel.isThread()) { - yield message.reply("๐Ÿงต We are already in a thread! I'm ready to assist you here."); - } - else { - yield message.reply("โŒ Threads can only be started in text channels."); - } + }))(); } - if (message.content === "!sponsor") { - yield message.reply("โณ Requesting account sponsorship..."); - try { - const response = yield fetch(`${BACKEND_URL}/api/account/${userId}/sponsor`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - const data = (yield response.json()); - if (data.success) { - yield message.reply(`โœ… Account sponsored successfully!\n๐Ÿ“ฌ Address: \`${data.address}\``); + if (message.content.startsWith("!trustline")) { + yield (0, performanceProfiler_1.withPerformanceProfiling)(commandName, 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + const args = message.content.split(" ").slice(1); + if (args.length < 1) { + return message.reply("Usage: !trustline [issuerDomain|issuerAddress]\nExample: !trustline USDC circle.com"); + } + const assetCode = args[0]; + const assetIssuer = args[1]; + if (!assetIssuer) { + return message.reply(`Please provide an issuer domain or address for ${assetCode}.`); + } + try { + yield message.reply(`๐Ÿ” Looking up asset ${assetCode} from ${assetIssuer}...`); + const op = yield (0, sdk_core_1.createTrustlineOperation)(assetCode, assetIssuer); + let response = `โœ… Found asset ${assetCode}!\n\n`; + response += `To add this trustline, you can use the following details in your wallet:\n`; + response += `**Asset:** ${assetCode}\n`; + response += `**Issuer:** \`${op.asset.issuer}\`\n\n`; + response += `*Note: In a future update, I will provide a direct signing link.*`; + yield message.reply(response); yield this.logAuditAction({ - action: 'SPONSOR_ACCOUNT', - triggeredBy: userId, - details: `Address: ${data.address}`, + action: 'TRUSTLINE_LOOKUP', + triggeredBy: message.author.id, + details: `Asset: ${assetCode}, Issuer: ${assetIssuer}`, success: true, timestamp: new Date().toISOString(), }); } - else { - yield message.reply(`โŒ Sponsorship failed: ${data.message}`); - yield this.logAuditAction({ - action: 'SPONSOR_ACCOUNT', - triggeredBy: userId, - details: `Failed: ${data.message}`, - success: false, - timestamp: new Date().toISOString(), - }); + catch (error) { + yield message.reply(`โŒ Error: ${error instanceof Error ? error.message : String(error)}`); } }))(); } - if (message.content.startsWith("!trustline")) { - const args = message.content.split(" ").slice(1); - if (args.length < 1) { - return message.reply("Usage: !trustline [issuerDomain|issuerAddress]\nExample: !trustline USDC circle.com"); + // #146: Dashboard command + if (message.content === '!dashboard') { + yield (0, performanceProfiler_1.withPerformanceProfiling)('!dashboard', 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + yield message.reply(`๐Ÿ“Š **Chen Pilot Dashboard**\n\nAccess your admin dashboard here:\n๐Ÿ”— ${DASHBOARD_URL}\n\n*Note: You must be logged in to view the dashboard.*`); + }))(); + } + // #148: /validate command for Stellar asset verification + if (message.content.startsWith('!validate')) { + yield (0, performanceProfiler_1.withPerformanceProfiling)(commandName, 'discord', userId, () => __awaiter(this, void 0, void 0, function* () { + const args = message.content.split(' ').slice(1); + if (args.length < 2) { + return message.reply('Usage: !validate \nExample: !validate USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + } + const [assetCode, issuerAddress] = args; + yield message.reply(`๐Ÿ” Verifying asset **${assetCode}** from issuer \`${issuerAddress.slice(0, 8)}...\``); + try { + const result = yield this.verificationService.verifyAsset(assetCode, issuerAddress); + const statusEmoji = result.status === 'VERIFIED' ? 'โœ…' : result.status === 'MALICIOUS' ? '๐Ÿšจ' : 'โš ๏ธ'; + let reply = `${statusEmoji} **Asset Verification: ${result.status}**\n\n`; + reply += `**Asset:** ${assetCode}\n`; + reply += `**Issuer:** \`${issuerAddress}\`\n`; + if (result.domain) + reply += `**Domain:** ${result.domain}\n`; + if (result.details) + reply += `**Details:** ${result.details}\n`; + reply += `\n**Safe to use:** ${result.isSafe ? 'Yes โœ…' : 'No โŒ'}`; + yield message.reply(reply); + } + catch (error) { + yield message.reply(`โŒ Verification error: ${error instanceof Error ? error.message : String(error)}`); + } + }))(); + } + // #125: Multisig wizard command + if (message.content === '!multisig') { + if (!isDM(message)) { + yield rejectPublicChannel(message); + return; } - const assetCode = args[0]; - const assetIssuer = args[1]; - if (!assetIssuer) { - return message.reply(`Please provide an issuer domain or address for ${assetCode}.`); + const response = this.multisigWizard.startWizard(userId, 'discord'); + yield message.reply(response.message); + } + // Handle wizard input (for active wizard sessions) + const wizardState = this.multisigWizard.getWizardState(userId, 'discord'); + if (wizardState && !WIZARD_COMMANDS.includes(message.content.split(' ')[0])) { + const response = this.multisigWizard.processInput(userId, 'discord', message.content); + yield message.reply(response.message); + } + // #118: !currency command โ€” set preferred report currency + if (message.content.startsWith('!currency')) { + const arg = (_b = message.content.split(' ')[1]) === null || _b === void 0 ? void 0 : _b.toUpperCase(); + if (!arg || !SUPPORTED_CURRENCIES.includes(arg)) { + return message.reply(`Usage: !currency \nCurrent: **${(_c = this.userCurrency.get(userId)) !== null && _c !== void 0 ? _c : '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 = (_d = this.userCurrency.get(userId)) !== null && _d !== void 0 ? _d : 'USD'; + yield message.reply(`โณ Fetching portfolio report in **${currency}**...`); try { - yield message.reply(`๐Ÿ” Looking up asset ${assetCode} from ${assetIssuer}...`); - const op = yield (0, sdk_core_1.createTrustlineOperation)(assetCode, assetIssuer); - let response = `โœ… Found asset ${assetCode}!\n\n`; - response += `To add this trustline, you can use the following details in your wallet:\n`; - response += `**Asset:** ${assetCode}\n`; - response += `**Issuer:** \`${op.asset.issuer}\`\n\n`; - response += `*Note: In a future update, I will provide a direct signing link.*`; - yield message.reply(response); - yield this.logAuditAction({ - action: 'TRUSTLINE_LOOKUP', - triggeredBy: message.author.id, - details: `Asset: ${assetCode}, Issuer: ${assetIssuer}`, - success: true, - timestamp: new Date().toISOString(), - }); + const res = yield fetch(`${BACKEND_URL}/api/portfolio/${userId}?currency=${currency}`); + if (!res.ok) + throw new Error(`HTTP ${res.status}`); + const data = yield res.json(); + 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 (error) { - yield message.reply(`โŒ Error: ${error instanceof Error ? error.message : String(error)}`); + catch (_g) { + return message.reply(`โŒ Could not fetch portfolio. Make sure your account is registered.`); } } - // #146: Dashboard command - if (message.content === '!dashboard') { - yield message.reply(`๐Ÿ“Š **Chen Pilot Dashboard**\n\nAccess your admin dashboard here:\n๐Ÿ”— ${DASHBOARD_URL}\n\n*Note: You must be logged in to view the dashboard.*`); - } - // #148: /validate command for Stellar asset verification - if (message.content.startsWith('!validate')) { + // #119: !alert command โ€” set a price alert + if (message.content.startsWith('!alert')) { const args = message.content.split(' ').slice(1); - if (args.length < 2) { - return message.reply('Usage: !validate \nExample: !validate USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + if (args.length < 3) { + return message.reply('Usage: !alert [USD|XLM|BTC]\nExample: !alert XLM above 0.15 USD'); } - const [assetCode, issuerAddress] = args; - yield message.reply(`๐Ÿ” Verifying asset **${assetCode}** from issuer \`${issuerAddress.slice(0, 8)}...\``); + const [assetCode, conditionRaw, priceRaw, currencyRaw] = args; + const condition = conditionRaw.toLowerCase(); + 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 = ((_f = (_e = currencyRaw === null || currencyRaw === void 0 ? void 0 : currencyRaw.toUpperCase()) !== null && _e !== void 0 ? _e : this.userCurrency.get(userId)) !== null && _f !== void 0 ? _f : 'USD'); + if (!SUPPORTED_CURRENCIES.includes(currency)) { + return message.reply(`โŒ Currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`); + } + const alertId = `${userId}-${assetCode}-${Date.now()}`; + const alert = { 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`; + } + 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(', ')}**`); + } + 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(', ')}**`); + } + yield message.reply('๐Ÿ” Discovering trending Stellar assets...'); try { - const result = yield this.verificationService.verifyAsset(assetCode, issuerAddress); - const statusEmoji = result.status === 'VERIFIED' ? 'โœ…' : result.status === 'MALICIOUS' ? '๐Ÿšจ' : 'โš ๏ธ'; - let reply = `${statusEmoji} **Asset Verification: ${result.status}**\n\n`; - reply += `**Asset:** ${assetCode}\n`; - reply += `**Issuer:** \`${issuerAddress}\`\n`; - if (result.domain) - reply += `**Domain:** ${result.domain}\n`; - if (result.details) - reply += `**Details:** ${result.details}\n`; - reply += `\n**Safe to use:** ${result.isSafe ? 'Yes โœ…' : 'No โŒ'}`; - yield message.reply(reply); + const res = yield fetch(`${BACKEND_URL}/api/assets/trending`); + if (!res.ok) + throw new Error(`HTTP ${res.status}`); + const assets = yield res.json(); + 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 (error) { - yield message.reply(`โŒ Verification error: ${error instanceof Error ? error.message : String(error)}`); + catch (_h) { + return message.reply('โŒ Could not fetch trending assets. Please try again later.'); } } - })); + }))); yield this.client.login(token); + this.startAlertPolling(); + // #128: Start market overview scheduler + this.startMarketOverviewScheduler(); console.log("โœ… Discord bot initialized."); }); } + // #120: Check if message author has an advanced role + hasAdvancedRole(message) { + if (!message.member) + return false; + return message.member.roles.cache.some((r) => ADVANCED_ROLE_NAMES.includes(r.name)); + } + // #119: Poll prices and fire triggered alerts via DM + startAlertPolling() { + this.alertCheckInterval = setInterval(() => __awaiter(this, void 0, void 0, function* () { + const pending = [...this.priceAlerts.values()].filter(a => !a.triggered); + if (!pending.length) + return; + for (const alert of pending) { + try { + const res = yield fetch(`${BACKEND_URL}/api/price/${alert.assetCode}?currency=${alert.currency}`); + if (!res.ok) + continue; + const { price } = yield res.json(); + const triggered = alert.condition === 'above' ? price >= alert.targetPrice : price <= alert.targetPrice; + if (!triggered) + continue; + alert.triggered = true; + const channelId = this.userChannels.get(alert.userId); + if (!channelId) + continue; + const channel = this.client.channels.cache.get(channelId); + if (!channel) + continue; + yield channel.send(`๐Ÿ”” **Price Alert Triggered!**\n**${alert.assetCode}** is now ${alert.condition} **${alert.targetPrice} ${alert.currency}** (current: ${price} ${alert.currency})`); + } + catch ( /* ignore per-alert errors */_a) { /* ignore per-alert errors */ } + } + }), 60000); // check every minute + } + logAuditAction(entry) { + return __awaiter(this, void 0, void 0, function* () { + var _a; + if (!this.auditLogChannelId || !this.client) + return; + try { + const ch = this.client.channels.cache.get(this.auditLogChannelId); + if (ch && typeof ch.send === 'function') { + yield ch.send(`๐Ÿ“ Audit: ${entry.action} by ${entry.triggeredBy} โ€” ${(_a = entry.details) !== null && _a !== void 0 ? _a : ''}`); + } + } + catch (e) { + console.error('Audit log failed', e); + } + }); + } // #147: Announce a new GitHub release to all registered announcement channels announceRelease(channelId, release) { return __awaiter(this, void 0, void 0, function* () { @@ -364,5 +749,112 @@ class DiscordAdapter { } }); } + // #117: Send interactive welcome message to new server members + sendWelcomeMessage(member) { + return __awaiter(this, void 0, void 0, function* () { + const username = member.user.username; + const welcomeChannel = member.guild.systemChannel; + // Try to DM the member first, fall back to the server's system channel + const sendMessage = (content) => __awaiter(this, void 0, void 0, function* () { + try { + yield member.send(content); + return 'dm'; + } + catch (_a) { + // Cannot DM โ€” member likely has DMs disabled + if (welcomeChannel) { + yield welcomeChannel.send({ content, allowedMentions: { users: [member.id] } }); + return 'channel'; + } + return null; + } + }); + // Step 1: Initial welcome greeting + const greeting = `๐ŸŽ‰ **Welcome to the Chen Pilot Community, ${username}!** ๐ŸŽ‰ + +I'm **Chen Pilot**, your AI-powered Stellar DeFi assistant! I'm here to help you navigate the Stellar ecosystem, manage your assets, and discover decentralized finance opportunities. + +Let me walk you through everything you can do with me! ๐Ÿš€`; + const sentVia = yield sendMessage(greeting); + if (!sentVia) { + console.warn(`โš ๏ธ Could not send welcome message to ${member.id}: no DM access and no system channel`); + return; + } + // Log welcome event + yield this.logAuditAction({ + action: 'WELCOME_MESSAGE_SENT', + triggeredBy: member.id, + details: `Username: ${username}, Sent via: ${sentVia === 'dm' ? 'DM' : 'system channel'}`, + success: true, + timestamp: new Date().toISOString(), + }); + // Small delay between messages for readability + const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + yield delay(1000); + // Step 2: Wallet connection guide + const walletGuide = `**๐Ÿ”— Step 1: Connect Your Stellar Wallet** + +To get started with DeFi on Stellar, you need a wallet. Here's how: + +1๏ธโƒฃ **Get a Wallet**: Download *Freighter* (Stellar's official browser extension) from \`freighter.app\` +2๏ธโƒฃ **Fund Your Account**: Use \`!sponsor\` to request free account sponsorship (covers minimum balance) +3๏ธโƒฃ **Trustlines**: Use \`!trustline \` to add assets like **USDC**, **XLM**, etc. +4๏ธโƒฃ **Verify**: Use \`!validate \` to check if an asset is safe before interacting + +> ๐Ÿ’ก *Tip: Always verify unknown assets with \`!validate\` to avoid scams!*`; + yield sendMessage(walletGuide); + yield delay(1000); + // Step 3: Essential commands overview + const commandsOverview = `**๐Ÿ“‹ Step 2: Essential Commands** + +Here are the key commands to get started: + +โ€ข **!help** โ€” List all available features +โ€ข **!balance** โ€” Check your wallet balance (DM only) +โ€ข **!report** โ€” Portfolio summary in your chosen currency +โ€ข **!currency ** โ€” Set your preferred reporting currency +โ€ข **!ping** โ€” Check bot latency and backend health +โ€ข **!alert ** โ€” Set price alerts +โ€ข **!alerts** โ€” View your active alerts +โ€ข **!discover** โ€” Explore trending Stellar assets (requires role) +โ€ข **!dashboard** โ€” Open the admin dashboard + +> ๐Ÿ”’ *Commands marked "DM only" must be sent in a private message for security.*`; + yield sendMessage(commandsOverview); + yield delay(1000); + // Step 4: Advanced features teaser + const advancedTeaser = `**โšก Step 3: Advanced Features** + +Ready to level up? Here's what else I can do: + +โ€ข **๐Ÿ” Multi-Sig Wallets**: Use \`!multisig\` in DMs to set up multi-signature security +โ€ข **๐Ÿงต Support Threads**: Type \`!thread\` to create a dedicated support session +โ€ข **๐Ÿ“Š Price Alerts**: Stay on top of market movements with \`!alert\` +โ€ข **๐Ÿ” Asset Verification**: Protect yourself with \`!validate\` +โ€ข **๐Ÿ“ˆ Market Overview**: Get daily market digests (if configured) + +New features are constantly being added โ€” type **!help** anytime to see what's new! + +--- + +**๐Ÿš€ Ready to dive in?** Start by setting your reporting currency with \`!currency\`, then use \`!sponsor\` to fund your account, and you're on your way!`; + yield sendMessage(advancedTeaser); + yield delay(1000); + // Step 5: Final tips + const finalTips = `**๐Ÿ’ก Pro Tips** + +โœ… **Use DMs for sensitive commands** โ€” Commands like \`!balance\` and \`!sponsor\` only work in DMs for your safety +โœ… **Rate limits apply** โ€” Please wait 2 seconds between commands to avoid flooding +โœ… **Report scams** โ€” Suspicious links are automatically detected and flagged +โœ… **Stay updated** โ€” Type \`!help\` anytime for the latest features + +If you ever need help, just send \`!help\` or type \`!thread\` to start a support conversation. + +**Welcome aboard, ${username}! Let's build the future of DeFi on Stellar together! ๐ŸŒŸ** + +โ€” *Chen Pilot Team*`; + yield sendMessage(finalTips); + }); + } } exports.DiscordAdapter = DiscordAdapter; diff --git a/packages/bot/src/adapters/discord.ts b/packages/bot/src/adapters/discord.ts index 2697230f..9fa819b5 100644 --- a/packages/bot/src/adapters/discord.ts +++ b/packages/bot/src/adapters/discord.ts @@ -454,6 +454,19 @@ export class DiscordAdapter { await message.reply( `๐Ÿ” Looking up asset ${assetCode} from ${assetIssuer}...` ); + + const gate = await this.verificationService.canExecuteTrustline( + assetCode, + assetIssuer + ); + + if (!gate.allowed) { + await message.reply( + `๐Ÿšซ Trustline blocked: ${gate.reason || gate.trustResult.details}` + ); + return; + } + const op = await createTrustlineOperation(assetCode, assetIssuer); let response = `โœ… Found asset ${assetCode}!\n\n`; @@ -462,11 +475,15 @@ export class DiscordAdapter { response += `**Issuer:** \`${(op as any).asset.issuer}\`\n\n`; response += `*Note: In a future update, I will provide a direct signing link.*`; + if (gate.trustResult.status === 'UNVERIFIED') { + response += `\n\nโš ๏ธ This asset remains unverified. Proceed with caution.`; + } + await message.reply(response); await this.logAuditAction({ action: 'TRUSTLINE_LOOKUP', triggeredBy: message.author.id, - details: `Asset: ${assetCode}, Issuer: ${assetIssuer}`, + details: `Asset: ${assetCode}, Issuer: ${assetIssuer}, TrustStatus: ${gate.trustResult.status}`, success: true, timestamp: new Date().toISOString(), }); diff --git a/packages/bot/src/adapters/telegram.js b/packages/bot/src/adapters/telegram.js index 925d6063..a554c9eb 100644 --- a/packages/bot/src/adapters/telegram.js +++ b/packages/bot/src/adapters/telegram.js @@ -13,10 +13,19 @@ exports.TelegramAdapter = void 0; const telegraf_1 = require("telegraf"); const sdk_core_1 = require("@chen-pilot/sdk-core"); const assetVerification_1 = require("../assetVerification"); -const DASHBOARD_URL = process.env.DASHBOARD_URL || `${process.env.API_BASE_URL || 'http://localhost:2333'}/dashboard`; +const rateLimiter_1 = require("../rateLimiter"); +const performanceProfiler_1 = require("../performanceProfiler"); +const multisigWizard_1 = require("../multisigWizard"); +const BACKEND_URL = process.env.BACKEND_URL || process.env.API_BASE_URL || 'http://localhost:2333'; +const DASHBOARD_URL = process.env.DASHBOARD_URL || `${BACKEND_URL}/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']; +// Commands that start a wizard +const WIZARD_COMMANDS = ['/multisig']; +// Commands that require stricter rate limiting +const SENSITIVE_COMMANDS = ['/trustline', '/validate']; function isDM(ctx) { var _a; return ((_a = ctx.chat) === null || _a === void 0 ? void 0 : _a.type) === 'private'; @@ -33,6 +42,11 @@ class TelegramAdapter { this.lastCommandTime = new Map(); this.token = token; this.verificationService = new assetVerification_1.AssetVerificationService(HORIZON_URL); + // #125: Initialize multisig wizard + this.multisigWizard = new multisigWizard_1.MultisigWizard(); + // #123: Initialize rate limiters + this.defaultRateLimiter = new rateLimiter_1.RateLimiter(rateLimiter_1.DEFAULT_RATE_LIMIT); + this.strictRateLimiter = new rateLimiter_1.RateLimiter(rateLimiter_1.STRICT_RATE_LIMIT); } // #145: Returns true if the user is flooding (within debounce window) isFlooding(userId) { @@ -44,6 +58,21 @@ class TelegramAdapter { this.lastCommandTime.set(userId, now); return false; } + // #123: Check rate limit for a user and command + checkRateLimit(userId, command) { + // Determine which rate limiter to use based on command + const isSensitive = SENSITIVE_COMMANDS.some(cmd => command.startsWith(cmd)); + const rateLimiter = isSensitive ? this.strictRateLimiter : this.defaultRateLimiter; + const status = rateLimiter.check(String(userId)); + if (!status.allowed) { + const retryAfter = status.retryAfter || 60; + return { + allowed: false, + message: `โณ Rate limit exceeded. Please wait ${retryAfter} seconds before trying again.` + }; + } + return { allowed: true }; + } init() { return __awaiter(this, void 0, void 0, function* () { if (!this.token) { @@ -53,40 +82,20 @@ class TelegramAdapter { this.bot = new telegraf_1.Telegraf(this.token); // #145: Middleware to debounce all incoming messages/commands this.bot.use((ctx, next) => __awaiter(this, void 0, void 0, function* () { - var _a; + var _a, _b, _c; const userId = (_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id; if (userId && this.isFlooding(userId)) { yield ctx.reply("โณ Please wait a moment before sending another command."); return; } - return next(); - })); - this.bot.start((ctx) => ctx.reply('Welcome to Chen Pilot! I am your AI-powered Stellar DeFi assistant.')); - this.bot.help((ctx) => ctx.reply('Commands: /start, /balance, /swap, /trustline, /dashboard, /validate')); - this.bot.command('trustline', (ctx) => __awaiter(this, void 0, void 0, function* () { - const args = ctx.message.text.split(' ').slice(1); - if (args.length < 1) { - return ctx.reply("Usage: /trustline [issuerDomain|issuerAddress]\nExample: /trustline USDC circle.com"); - } - const assetCode = args[0]; - const assetIssuer = args[1]; - if (!assetIssuer) { - return ctx.reply(`Please provide an issuer domain or address for ${assetCode}.`); - } - try { - yield ctx.reply(`๐Ÿ” Looking up asset ${assetCode} from ${assetIssuer}...`); - const op = yield (0, sdk_core_1.createTrustlineOperation)(assetCode, assetIssuer); - // In a real scenario, we would generate a signing link (e.g., Albedo or Stellar Laboratory) - // For now, we'll return the operation details - let message = `โœ… Found asset ${assetCode}!\n\n`; - message += `To add this trustline, you can use the following details in your wallet:\n`; - message += `Asset: ${assetCode}\n`; - message += `Issuer: ${op.asset.issuer}\n\n`; - message += `Note: In a future update, I will provide a direct signing link.`; - yield ctx.reply(message, { parse_mode: "HTML" }); - } - catch (error) { - yield ctx.reply(`โŒ Error: ${error instanceof Error ? error.message : String(error)}`); + // #123: Rate limit check + const command = ((_c = (_b = ctx.message) === null || _b === void 0 ? void 0 : _b.text) === null || _c === void 0 ? void 0 : _c.split(' ')[0]) || ''; + if (userId) { + const rateLimitResult = this.checkRateLimit(userId, command); + if (!rateLimitResult.allowed) { + yield ctx.reply(rateLimitResult.message); + return; + } } return next(); })); @@ -131,6 +140,34 @@ class TelegramAdapter { } }))(); })); + // #134: Ping command โ€” measure end-to-end latency + this.bot.command('ping', (ctx) => __awaiter(this, void 0, void 0, function* () { + var _a; + const userId = String(((_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id) || 'unknown'); + yield (0, performanceProfiler_1.withPerformanceProfiling)('/ping', 'telegram', userId, () => __awaiter(this, void 0, void 0, function* () { + const startTime = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = yield fetch(`${BACKEND_URL}/api/health`, { + method: 'GET', + signal: controller.signal, + }); + clearTimeout(timeout); + const roundtripMs = Date.now() - startTime; + if (response.ok) { + yield ctx.reply(`๐Ÿ“ Pong!\n\n๐Ÿ“ก End-to-End Latency: ${roundtripMs}ms\nโœ… Backend: Online`, { parse_mode: 'HTML' }); + } + else { + yield ctx.reply(`๐Ÿ“ Pong!\n\n๐Ÿ“ก End-to-End Latency: ${roundtripMs}ms\nโš ๏ธ Backend: Returned HTTP ${response.status}`, { parse_mode: 'HTML' }); + } + } + catch (_a) { + const roundtripMs = Date.now() - startTime; + yield ctx.reply(`๐Ÿ“ Pong!\n\n๐Ÿ“ก End-to-End Latency: ${roundtripMs}ms\nโŒ Backend: Unreachable`, { parse_mode: 'HTML' }); + } + }))(); + })); // #146: Dashboard command this.bot.command('dashboard', (ctx) => __awaiter(this, void 0, void 0, function* () { var _a; @@ -139,33 +176,58 @@ class TelegramAdapter { yield ctx.reply(`๐Ÿ“Š Chen Pilot Dashboard\n\nAccess your admin dashboard here:\n๐Ÿ”— Open Dashboard\n\nNote: You must be logged in to view the dashboard.`, { parse_mode: 'HTML' }); }))(); })); - // #146: Dashboard command - this.bot.command('dashboard', (ctx) => __awaiter(this, void 0, void 0, function* () { - yield ctx.reply(`๐Ÿ“Š Chen Pilot Dashboard\n\nAccess your admin dashboard here:\n๐Ÿ”— Open Dashboard\n\nNote: You must be logged in to view the dashboard.`, { parse_mode: 'HTML' }); - })); // #148: /validate command for Stellar asset verification this.bot.command('validate', (ctx) => __awaiter(this, void 0, void 0, function* () { - const args = ctx.message.text.split(' ').slice(1); - if (args.length < 2) { - return ctx.reply('Usage: /validate \nExample: /validate USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); - } - const [assetCode, issuerAddress] = args; - yield ctx.reply(`๐Ÿ” Verifying asset ${assetCode} from issuer ${issuerAddress.slice(0, 8)}...`, { parse_mode: 'HTML' }); - try { - const result = yield this.verificationService.verifyAsset(assetCode, issuerAddress); - const statusEmoji = result.status === 'VERIFIED' ? 'โœ…' : result.status === 'MALICIOUS' ? '๐Ÿšจ' : 'โš ๏ธ'; - let reply = `${statusEmoji} Asset Verification: ${result.status}\n\n`; - reply += `Asset: ${assetCode}\n`; - reply += `Issuer: ${issuerAddress}\n`; - if (result.domain) - reply += `Domain: ${result.domain}\n`; - if (result.details) - reply += `Details: ${result.details}\n`; - reply += `\nSafe to use: ${result.isSafe ? 'Yes โœ…' : 'No โŒ'}`; - yield ctx.reply(reply, { parse_mode: 'HTML' }); + var _a; + const userId = String(((_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id) || 'unknown'); + const commandName = (0, performanceProfiler_1.extractCommandName)(ctx.message.text, 'telegram'); + yield (0, performanceProfiler_1.withPerformanceProfiling)(commandName, 'telegram', userId, () => __awaiter(this, void 0, void 0, function* () { + const args = ctx.message.text.split(' ').slice(1); + if (args.length < 2) { + return ctx.reply('Usage: /validate \nExample: /validate USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + } + const [assetCode, issuerAddress] = args; + yield ctx.reply(`๐Ÿ” Verifying asset ${assetCode} from issuer ${issuerAddress.slice(0, 8)}...`, { parse_mode: 'HTML' }); + try { + const result = yield this.verificationService.verifyAsset(assetCode, issuerAddress); + const statusEmoji = result.status === 'VERIFIED' ? 'โœ…' : result.status === 'MALICIOUS' ? '๐Ÿšจ' : 'โš ๏ธ'; + let reply = `${statusEmoji} Asset Verification: ${result.status}\n\n`; + reply += `Asset: ${assetCode}\n`; + reply += `Issuer: ${issuerAddress}\n`; + if (result.domain) + reply += `Domain: ${result.domain}\n`; + if (result.details) + reply += `Details: ${result.details}\n`; + reply += `\nSafe to use: ${result.isSafe ? 'Yes โœ…' : 'No โŒ'}`; + yield ctx.reply(reply, { parse_mode: 'HTML' }); + } + catch (error) { + yield ctx.reply(`โŒ Verification error: ${error instanceof Error ? error.message : String(error)}`); + } + }))(); + })); + // #125: Multisig wizard command + this.bot.command('multisig', (ctx) => __awaiter(this, void 0, void 0, function* () { + var _a; + const userId = String(((_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id) || 'unknown'); + if (!isDM(ctx)) { + yield rejectPublicChannel(ctx); + return; } - catch (error) { - yield ctx.reply(`โŒ Verification error: ${error instanceof Error ? error.message : String(error)}`); + const response = this.multisigWizard.startWizard(userId, 'telegram'); + yield ctx.reply(response.message); + })); + // #125: Handle wizard input (for active wizard sessions) + this.bot.use((ctx, next) => __awaiter(this, void 0, void 0, function* () { + var _a, _b; + const userId = String(((_a = ctx.from) === null || _a === void 0 ? void 0 : _a.id) || 'unknown'); + const text = ((_b = ctx.message) === null || _b === void 0 ? void 0 : _b.text) || ''; + const command = text.split(' ')[0]; + const wizardState = this.multisigWizard.getWizardState(userId, 'telegram'); + if (wizardState && !WIZARD_COMMANDS.includes(command)) { + const response = this.multisigWizard.processInput(userId, 'telegram', text); + yield ctx.reply(response.message); + return; } return next(); })); @@ -175,6 +237,7 @@ class TelegramAdapter { { command: "balance", description: "Check wallet balance" }, { command: "swap", description: "Swap assets" }, { command: "trustline", description: "Add trustline" }, + { command: "multisig", description: "Setup multisig wallet" }, { command: "help", description: "Show help" }, ]); this.bot.launch(); diff --git a/packages/bot/src/adapters/telegram.ts b/packages/bot/src/adapters/telegram.ts index 3e8b60ec..58723663 100644 --- a/packages/bot/src/adapters/telegram.ts +++ b/packages/bot/src/adapters/telegram.ts @@ -142,6 +142,17 @@ export class TelegramAdapter { await ctx.reply( `๐Ÿ” Looking up asset ${assetCode} from ${assetIssuer}...` ); + + const gate = await this.verificationService.canExecuteTrustline( + assetCode, + assetIssuer + ); + + if (!gate.allowed) { + await ctx.reply(`๐Ÿšซ Trustline blocked: ${gate.reason || gate.trustResult.details}`); + return; + } + const op = await createTrustlineOperation(assetCode, assetIssuer); // In a real scenario, we would generate a signing link (e.g., Albedo or Stellar Laboratory) @@ -150,6 +161,9 @@ export class TelegramAdapter { message += `To add this trustline, you can use the following details in your wallet:\n`; message += `Asset: ${assetCode}\n`; message += `Issuer: ${(op as any).asset.issuer}\n\n`; + if (gate.trustResult.status === 'UNVERIFIED') { + message += `Warning: This asset is unverified. Proceed with caution.\n\n`; + } message += `Note: In a future update, I will provide a direct signing link.`; await ctx.reply(message, { parse_mode: "HTML" }); diff --git a/packages/bot/src/assetVerification.ts b/packages/bot/src/assetVerification.ts index d0bef854..17d0eb33 100644 --- a/packages/bot/src/assetVerification.ts +++ b/packages/bot/src/assetVerification.ts @@ -1,49 +1,41 @@ -import * as StellarSdk from 'stellar-sdk'; +import { + StellarTrustFramework, + AssetTrustResult, + TrustPolicyConfig, + StellarMetadataManager, +} from "@chen-pilot/sdk-core"; + +const parseCsv = (value?: string): string[] => + value + ? value.split(",").map((entry) => entry.trim()).filter(Boolean) + : []; -export interface VerificationResult { - isSafe: boolean; - domain?: string; - status: 'VERIFIED' | 'UNVERIFIED' | 'MALICIOUS'; - details?: string; -} - -/** - * #148: Verifies a Stellar asset against its issuer's home_domain and TOML (SEP-1). - */ export class AssetVerificationService { - private horizonServer: StellarSdk.Horizon.Server; - - constructor(horizonUrl: string) { - this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + private trustFramework: StellarTrustFramework; + + constructor(horizonUrl: string, metadataManager?: StellarMetadataManager) { + const config: TrustPolicyConfig = { + horizonUrl, + enableScamDetection: true, + allowUnverifiedAssetUsage: false, + requireVerifiedAssetUsage: false, + safeIssuers: parseCsv(process.env.TRUST_SAFE_ISSUERS), + blockedIssuers: parseCsv(process.env.TRUST_BLOCKED_ISSUERS), + safeAssets: parseCsv(process.env.TRUST_SAFE_ASSETS), + blockedAssets: parseCsv(process.env.TRUST_BLOCKED_ASSETS), + safeDomains: parseCsv(process.env.TRUST_SAFE_DOMAINS), + blockedDomains: parseCsv(process.env.TRUST_BLOCKED_DOMAINS), + metadataManager, + }; + + this.trustFramework = new StellarTrustFramework(config); } - async verifyAsset(assetCode: string, issuerAddress: string): Promise { - try { - const issuerAccount = await this.horizonServer.loadAccount(issuerAddress); - const homeDomain = issuerAccount.home_domain; - - if (!homeDomain) { - return { isSafe: false, status: 'UNVERIFIED', details: 'No home_domain set on issuer account.' }; - } - - const toml = await StellarSdk.StellarToml.Resolver.resolve(homeDomain); - const currencies: Record[] = toml.CURRENCIES || []; - const isListed = currencies.some( - (c) => c['code'] === assetCode && c['issuer'] === issuerAddress - ); - - if (isListed) { - return { isSafe: true, domain: homeDomain, status: 'VERIFIED' }; - } + async verifyAsset(assetCode: string, issuerAddress: string): Promise { + return this.trustFramework.verifyAsset(assetCode, issuerAddress); + } - return { - isSafe: false, - domain: homeDomain, - status: 'MALICIOUS', - details: 'Asset issuer claims domain but asset is not listed in TOML file.', - }; - } catch { - return { isSafe: false, status: 'UNVERIFIED', details: 'Verification failed due to network or TOML resolution error.' }; - } + async canExecuteTrustline(assetCode: string, issuerAddress: string) { + return this.trustFramework.canExecuteTrustline(assetCode, issuerAddress); } } diff --git a/packages/bot/src/marketOverview.js b/packages/bot/src/marketOverview.js new file mode 100644 index 00000000..94636497 --- /dev/null +++ b/packages/bot/src/marketOverview.js @@ -0,0 +1,141 @@ +"use strict"; +/** + * Market Overview Service + * + * Fetches and formats daily market summaries of top-performing Stellar assets + * for automated daily digest posts in Discord channels. + */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MarketOverviewService = void 0; +class MarketOverviewService { + constructor() { + this.BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000"; + } + /** + * Fetch market overview data from the backend + */ + fetchMarketOverview() { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + try { + // Fetch top gainers + const gainersRes = yield fetch(`${this.BACKEND_URL}/api/assets/trending?sort=price_change&order=desc&limit=5`); + const topGainers = gainersRes.ok ? yield gainersRes.json() : []; + // Fetch top losers + const losersRes = yield fetch(`${this.BACKEND_URL}/api/assets/trending?sort=price_change&order=asc&limit=5`); + const topLosers = losersRes.ok ? yield losersRes.json() : []; + // Fetch top by volume + const volumeRes = yield fetch(`${this.BACKEND_URL}/api/assets/trending?sort=volume&order=desc&limit=5`); + const topVolume = volumeRes.ok ? yield volumeRes.json() : []; + // Fetch network status + let networkStatus; + try { + const statusRes = yield fetch(`${this.BACKEND_URL}/api/network/status`); + if (statusRes.ok) { + const statusData = yield statusRes.json(); + networkStatus = { + isHealthy: ((_a = statusData.health) === null || _a === void 0 ? void 0 : _a.isHealthy) || false, + latestLedger: ((_b = statusData.health) === null || _b === void 0 ? void 0 : _b.latestLedger) || 0 + }; + } + } + catch (_c) { + // Network status fetch failed, continue without it + } + return { + timestamp: new Date().toISOString(), + topGainers, + topLosers, + topVolume, + networkStatus + }; + } + catch (error) { + console.error('Error fetching market overview:', error); + throw new Error('Failed to fetch market overview data'); + } + }); + } + /** + * Format market overview data for Discord message + */ + formatMarketOverviewMessage(data) { + const date = new Date(data.timestamp).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + let message = `๐Ÿ“Š **Daily Market Overview - ${date}**\n\n`; + // Network status + if (data.networkStatus) { + const healthEmoji = data.networkStatus.isHealthy ? '๐ŸŸข' : '๐Ÿ”ด'; + message += `${healthEmoji} **Stellar Network Status:** Ledger ${data.networkStatus.latestLedger}\n\n`; + } + // Top Gainers + message += `๐Ÿ“ˆ **Top Gainers (24h)**\n`; + if (data.topGainers.length === 0) { + message += `No data available\n`; + } + else { + for (const asset of data.topGainers) { + const change = asset.priceChange24h >= 0 ? `+${asset.priceChange24h.toFixed(2)}%` : `${asset.priceChange24h.toFixed(2)}%`; + message += `โ€ข **${asset.code}** ${asset.domain ? `(${asset.domain})` : ''}\n`; + message += ` Price: $${asset.price.toFixed(4)} | 24h: ${change} | Vol: ${this.formatNumber(asset.volume24h)}\n`; + } + } + message += `\n`; + // Top Losers + message += `๐Ÿ“‰ **Top Losers (24h)**\n`; + if (data.topLosers.length === 0) { + message += `No data available\n`; + } + else { + for (const asset of data.topLosers) { + const change = asset.priceChange24h >= 0 ? `+${asset.priceChange24h.toFixed(2)}%` : `${asset.priceChange24h.toFixed(2)}%`; + message += `โ€ข **${asset.code}** ${asset.domain ? `(${asset.domain})` : ''}\n`; + message += ` Price: $${asset.price.toFixed(4)} | 24h: ${change} | Vol: ${this.formatNumber(asset.volume24h)}\n`; + } + } + message += `\n`; + // Top by Volume + message += `๐Ÿ’ฐ **Top by Volume (24h)**\n`; + if (data.topVolume.length === 0) { + message += `No data available\n`; + } + else { + for (const asset of data.topVolume) { + const change = asset.priceChange24h >= 0 ? `+${asset.priceChange24h.toFixed(2)}%` : `${asset.priceChange24h.toFixed(2)}%`; + message += `โ€ข **${asset.code}** ${asset.domain ? `(${asset.domain})` : ''}\n`; + message += ` Price: $${asset.price.toFixed(4)} | 24h: ${change} | Vol: ${this.formatNumber(asset.volume24h)}\n`; + } + } + message += `\n*Data provided by Chen Pilot*`; + return message; + } + /** + * Format large numbers for readability + */ + formatNumber(num) { + if (num >= 1000000000) { + return `${(num / 1000000000).toFixed(2)}B`; + } + if (num >= 1000000) { + return `${(num / 1000000).toFixed(2)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(2)}K`; + } + return num.toFixed(2); + } +} +exports.MarketOverviewService = MarketOverviewService; diff --git a/packages/bot/src/multisigWizard.js b/packages/bot/src/multisigWizard.js new file mode 100644 index 00000000..ca726332 --- /dev/null +++ b/packages/bot/src/multisigWizard.js @@ -0,0 +1,353 @@ +"use strict"; +/** + * Multisig Wizard Service + * + * Provides an interactive wizard to guide users through setting up + * a basic multi-signature configuration on their Stellar account. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MultisigWizard = void 0; +class MultisigWizard { + constructor() { + this.activeWizards = new Map(); + this.MAX_SIGNERS = 20; + this.MAX_THRESHOLD = 20; + this.MIN_THRESHOLD = 1; + } + /** + * Start a new wizard session for a user + */ + startWizard(userId, platform) { + const wizardId = `${platform}:${userId}`; + // Check if wizard already exists + if (this.activeWizards.has(wizardId)) { + return { + message: "โš ๏ธ You already have an active multisig wizard session. Use !multisig cancel to abort it.", + }; + } + const state = { + step: 1, + config: { + signers: [], + }, + userId, + platform, + }; + this.activeWizards.set(wizardId, state); + return { + message: this.getStepMessage(state), + nextStep: 1, + }; + } + /** + * Process user input in the wizard + */ + processInput(userId, platform, input) { + const wizardId = `${platform}:${userId}`; + const state = this.activeWizards.get(wizardId); + if (!state) { + return { + message: "โš ๏ธ No active wizard session. Use !multisig to start a new one.", + }; + } + const trimmedInput = input.trim().toLowerCase(); + // Handle cancel command + if (trimmedInput === 'cancel' || trimmedInput === 'abort' || trimmedInput === 'exit') { + this.activeWizards.delete(wizardId); + return { + message: "โŒ Multisig wizard cancelled. No changes were made.", + }; + } + // Handle reset command + if (trimmedInput === 'reset' || trimmedInput === 'restart') { + state.step = 1; + state.config = { signers: [] }; + return { + message: "๐Ÿ”„ Wizard reset. Let's start over!\n\n" + this.getStepMessage(state), + nextStep: 1, + }; + } + // Process based on current step + switch (state.step) { + case 1: + return this.handleStep1(state, input); + case 2: + return this.handleStep2(state, input); + case 3: + return this.handleStep3(state, input); + case 4: + return this.handleStep4(state, input); + case 5: + return this.handleStep5(state, input); + default: + return this.completeWizard(state); + } + } + /** + * Step 1: Ask for threshold + */ + handleStep1(state, input) { + const threshold = parseInt(input); + if (isNaN(threshold) || threshold < this.MIN_THRESHOLD || threshold > this.MAX_THRESHOLD) { + return { + message: `โš ๏ธ Invalid threshold. Please enter a number between ${this.MIN_THRESHOLD} and ${this.MAX_THRESHOLD}.`, + }; + } + state.config.threshold = threshold; + state.step = 2; + return { + message: this.getStepMessage(state), + nextStep: 2, + config: state.config, + }; + } + /** + * Step 2: Ask for number of signers + */ + handleStep2(state, input) { + const numSigners = parseInt(input); + if (isNaN(numSigners) || numSigners < 1 || numSigners > this.MAX_SIGNERS) { + return { + message: `โš ๏ธ Invalid number of signers. Please enter a number between 1 and ${this.MAX_SIGNERS}.`, + }; + } + if (numSigners < state.config.threshold) { + return { + message: `โš ๏ธ Number of signers must be at least equal to the threshold (${state.config.threshold}).`, + }; + } + state.config.signers = []; + state.step = 3; + return { + message: this.getStepMessage(state, numSigners), + nextStep: 3, + config: state.config, + }; + } + /** + * Step 3: Collect signer keys + */ + handleStep3(state, input) { + const signers = state.config.signers; + const targetSigners = parseInt(input.split(' ')[0]) || signers.length + 1; + // Check if we're adding a signer or moving to next step + if (input.toLowerCase() === 'done' || input.toLowerCase() === 'next') { + if (signers.length === 0) { + return { + message: "โš ๏ธ You need to add at least one signer. Enter a public key or type 'cancel' to abort.", + }; + } + state.step = 4; + return { + message: this.getStepMessage(state), + nextStep: 4, + config: state.config, + }; + } + // Validate Stellar public key + if (!this.isValidPublicKey(input)) { + return { + message: "โš ๏ธ Invalid Stellar public key. Please enter a valid public key (starts with 'G').", + }; + } + // Check for duplicates + if (signers.some(s => s.key === input)) { + return { + message: "โš ๏ธ This signer has already been added. Please enter a different key.", + }; + } + // Add signer with default weight + signers.push({ + key: input, + weight: 1, + }); + state.step = 3; // Stay on step 3 to collect more signers + const remaining = (state.config.threshold - signers.length); + const message = `โœ… Signer ${signers.length} added: \`${input.slice(0, 8)}...\`\n\n`; + if (signers.length < state.config.threshold) { + return { + message: message + `You need at least ${state.config.threshold} signers total. Add another key or type 'done' to continue.`, + nextStep: 3, + config: state.config, + }; + } + else { + return { + message: message + `Minimum threshold reached (${state.config.threshold}). Add more signers or type 'done' to continue.`, + nextStep: 3, + config: state.config, + }; + } + } + /** + * Step 4: Configure signer weights + */ + handleStep4(state, input) { + const signers = state.config.signers; + // Skip weight configuration if only one signer + if (signers.length === 1) { + signers[0].weight = 1; + state.step = 5; + return { + message: this.getStepMessage(state), + nextStep: 5, + config: state.config, + }; + } + // Parse input: "1 2" means set signer 1's weight to 2 + const parts = input.split(' '); + if (parts.length < 2) { + return { + message: "โš ๏ธ Invalid format. Use: \nExample: 1 2 (sets signer 1's weight to 2)", + }; + } + const signerIndex = parseInt(parts[0]) - 1; + const weight = parseInt(parts[1]); + if (isNaN(signerIndex) || signerIndex < 0 || signerIndex >= signers.length) { + return { + message: `โš ๏ธ Invalid signer number. Please enter a number between 1 and ${signers.length}.`, + }; + } + if (isNaN(weight) || weight < 1 || weight > this.MAX_THRESHOLD) { + return { + message: `โš ๏ธ Invalid weight. Please enter a number between 1 and ${this.MAX_THRESHOLD}.`, + }; + } + signers[signerIndex].weight = weight; + // Check if threshold can be met + const totalWeight = signers.reduce((sum, s) => sum + s.weight, 0); + if (totalWeight < state.config.threshold) { + return { + message: `โš ๏ธ Total weight (${totalWeight}) is less than threshold (${state.config.threshold}). Increase some weights.`, + }; + } + // Show current weights and ask if done + let weightList = signers.map((s, i) => `${i + 1}. \`${s.key.slice(0, 8)}...\`: weight ${s.weight}`).join('\n'); + return { + message: `โœ… Signer ${signerIndex + 1} weight set to ${weight}\n\nCurrent weights:\n${weightList}\n\nConfigure more weights or type 'done' to continue.`, + nextStep: 4, + config: state.config, + }; + } + /** + * Step 5: Confirm configuration + */ + handleStep5(state, input) { + const trimmedInput = input.trim().toLowerCase(); + if (trimmedInput === 'yes' || trimmedInput === 'y' || trimmedInput === 'confirm') { + return this.completeWizard(state); + } + if (trimmedInput === 'no' || trimmedInput === 'n') { + return { + message: "โŒ Configuration cancelled. Use !multisig to start over.", + }; + } + return { + message: "โš ๏ธ Please respond with 'yes' to confirm or 'no' to cancel.", + }; + } + /** + * Complete the wizard and return the final configuration + */ + completeWizard(state) { + const wizardId = `${state.platform}:${state.userId}`; + this.activeWizards.delete(wizardId); + const config = state.config; + // Validate final configuration + const totalWeight = config.signers.reduce((sum, s) => sum + s.weight, 0); + if (totalWeight < config.threshold) { + return { + message: `โš ๏ธ Configuration invalid: Total weight (${totalWeight}) is less than threshold (${config.threshold}). Please start over.`, + }; + } + return { + message: this.getSummaryMessage(config), + isComplete: true, + config, + }; + } + /** + * Get the message for the current wizard step + */ + getStepMessage(state, additionalInfo) { + var _a; + switch (state.step) { + case 1: + return `๐Ÿ” **Multisig Setup Wizard**\n\n` + + `Step 1/5: Set Threshold\n\n` + + `The threshold is the minimum number of signers required to authorize transactions.\n\n` + + `Please enter a threshold (1-20):`; + case 2: + return `๐Ÿ” **Multisig Setup Wizard**\n\n` + + `Step 2/5: Number of Signers\n\n` + + `How many signers will this account have? (1-20)\n\n` + + `Note: You need at least ${state.config.threshold} signers to meet the threshold.`; + case 3: + const currentCount = ((_a = state.config.signers) === null || _a === void 0 ? void 0 : _a.length) || 0; + return `๐Ÿ” **Multisig Setup Wizard**\n\n` + + `Step 3/5: Add Signers\n\n` + + `Signers added: ${currentCount}\n` + + `Signers needed: at least ${state.config.threshold}\n\n` + + `Enter a Stellar public key to add a signer, or type 'done' to continue.`; + case 4: + const signers = state.config.signers; + if (signers.length === 1) { + state.step = 5; + return this.getStepMessage(state); + } + let weightList = signers.map((s, i) => `${i + 1}. \`${s.key.slice(0, 8)}...\`: weight ${s.weight}`).join('\n'); + return `๐Ÿ” **Multisig Setup Wizard**\n\n` + + `Step 4/5: Configure Weights\n\n` + + `Current weights:\n${weightList}\n\n` + + `Use format: \n` + + `Example: 1 2 (sets signer 1's weight to 2)\n\n` + + `Type 'done' when finished.`; + case 5: + return `๐Ÿ” **Multisig Setup Wizard**\n\n` + + `Step 5/5: Confirm Configuration\n\n` + + `${this.getSummaryMessage(state.config)}\n\n` + + `Type 'yes' to confirm this configuration or 'no' to cancel.`; + default: + return "โš ๏ธ Invalid wizard state."; + } + } + /** + * Get a summary of the multisig configuration + */ + getSummaryMessage(config) { + const signersList = config.signers.map((s, i) => `${i + 1}. \`${s.key.slice(0, 8)}...\` (weight: ${s.weight})`).join('\n'); + const totalWeight = config.signers.reduce((sum, s) => sum + s.weight, 0); + return `**Configuration Summary:**\n\n` + + `๐Ÿ“Š Threshold: ${config.threshold}\n` + + `โš–๏ธ Total Weight: ${totalWeight}\n` + + `๐Ÿ‘ฅ Signers (${config.signers.length}):\n${signersList}`; + } + /** + * Validate a Stellar public key + */ + isValidPublicKey(key) { + // Stellar public keys start with 'G' and are 56 characters long + const stellarPublicKeyRegex = /^G[A-Z0-9]{55}$/; + return stellarPublicKeyRegex.test(key); + } + /** + * Get the current state of a user's wizard + */ + getWizardState(userId, platform) { + return this.activeWizards.get(`${platform}:${userId}`); + } + /** + * Cancel a user's wizard session + */ + cancelWizard(userId, platform) { + const wizardId = `${platform}:${userId}`; + return this.activeWizards.delete(wizardId); + } + /** + * Get the number of active wizards + */ + getActiveWizardCount() { + return this.activeWizards.size; + } +} +exports.MultisigWizard = MultisigWizard; diff --git a/packages/bot/src/rateLimiter.js b/packages/bot/src/rateLimiter.js new file mode 100644 index 00000000..885433e5 --- /dev/null +++ b/packages/bot/src/rateLimiter.js @@ -0,0 +1,106 @@ +"use strict"; +/** + * Rate Limiter for Bot Commands + * + * Implements a sliding window rate limiter to prevent individual users + * from flooding the bot with commands. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.STRICT_RATE_LIMIT = exports.DEFAULT_RATE_LIMIT = exports.RateLimiter = void 0; +class RateLimiter { + constructor(config) { + this.userTimestamps = new Map(); + this.config = config; + } + /** + * Check if a request is allowed for a given user + * + * @param userId - The user identifier + * @returns Rate limit status + */ + check(userId) { + const now = Date.now(); + const timestamps = this.userTimestamps.get(userId) || []; + // Filter out timestamps outside the current window + const windowStart = now - this.config.windowMs; + const validTimestamps = timestamps.filter(ts => ts > windowStart); + // Update the stored timestamps + this.userTimestamps.set(userId, validTimestamps); + const requestCount = validTimestamps.length; + const remaining = Math.max(0, this.config.maxRequests - requestCount); + const allowed = requestCount < this.config.maxRequests; + if (allowed) { + // Add current timestamp + validTimestamps.push(now); + this.userTimestamps.set(userId, validTimestamps); + } + else { + // Calculate retry after time (when oldest request expires) + const oldestTimestamp = validTimestamps[0]; + const retryAfter = Math.ceil((oldestTimestamp + this.config.windowMs - now) / 1000); + return { + allowed: false, + remaining: 0, + resetTime: oldestTimestamp + this.config.windowMs, + retryAfter, + }; + } + return { + allowed: true, + remaining: remaining - 1, + resetTime: now + this.config.windowMs, + }; + } + /** + * Reset rate limit for a specific user + * + * @param userId - The user identifier + */ + reset(userId) { + this.userTimestamps.delete(userId); + } + /** + * Clear all rate limit data (useful for testing) + */ + clear() { + this.userTimestamps.clear(); + } + /** + * Get current rate limit status without consuming a request + * + * @param userId - The user identifier + * @returns Current rate limit status + */ + getStatus(userId) { + const now = Date.now(); + const timestamps = this.userTimestamps.get(userId) || []; + // Filter out timestamps outside the current window + const windowStart = now - this.config.windowMs; + const validTimestamps = timestamps.filter(ts => ts > windowStart); + const requestCount = validTimestamps.length; + const remaining = Math.max(0, this.config.maxRequests - requestCount); + const allowed = requestCount < this.config.maxRequests; + let retryAfter; + if (!allowed && validTimestamps.length > 0) { + const oldestTimestamp = validTimestamps[0]; + retryAfter = Math.ceil((oldestTimestamp + this.config.windowMs - now) / 1000); + } + return { + allowed, + remaining, + resetTime: now + this.config.windowMs, + retryAfter, + }; + } +} +exports.RateLimiter = RateLimiter; +// Default rate limit configuration +exports.DEFAULT_RATE_LIMIT = { + maxRequests: 10, // 10 requests + windowMs: 60000, // per minute (60 seconds) +}; +// Strict rate limit for sensitive operations +exports.STRICT_RATE_LIMIT = { + maxRequests: 3, // 3 requests + windowMs: 60000, // per minute +}; diff --git a/packages/bot/src/scamDetection.js b/packages/bot/src/scamDetection.js new file mode 100644 index 00000000..2d0be3ba --- /dev/null +++ b/packages/bot/src/scamDetection.js @@ -0,0 +1,205 @@ +"use strict"; +/** + * Scam Link Detection Service + * + * Detects and flags obvious scam links in Discord messages to protect users + * from phishing, typosquatting, and other malicious URL patterns. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ScamDetectionService = void 0; +class ScamDetectionService { + constructor() { + // Known suspicious TLDs often used for scams + this.SUSPICIOUS_TLDS = [ + '.xyz', '.top', '.zip', '.mov', '.tk', '.ml', '.ga', '.cf', '.gq', + '.pw', '.cc', '.men', '.date', '.loan', '.win', '.review', '.trade' + ]; + // Common scam/ phishing keywords in URLs + this.SCAM_KEYWORDS = [ + 'free', 'bonus', 'giveaway', 'airdrop', 'claim', 'reward', + 'double', 'multiply', 'invest', 'profit', 'earn', 'crypto', + 'bitcoin', 'ethereum', 'stellar', 'xlm', 'wallet', 'connect', + 'verify', 'confirm', 'urgent', 'limited', 'exclusive', 'secret' + ]; + // Known legitimate domains to whitelist + this.WHITELISTED_DOMAINS = [ + 'stellar.org', 'discord.com', 'discord.gg', 'github.com', + 'reddit.com', 'twitter.com', 'x.com', 'medium.com' + ]; + // Typosquatting patterns for popular crypto sites + this.TYPOSQUAT_PATTERNS = [ + { target: 'stellar.org', patterns: ['stellaar.org', 'stelllar.org', 'stelar.org', 'stellr.org', 'stllar.org', 'stellarr.org'] }, + { target: 'discord.com', patterns: ['d1scord.com', 'disc0rd.com', 'discrod.com', 'diiscord.com'] }, + { target: 'github.com', patterns: ['githuub.com', 'githhub.com', 'githab.com', 'gitthub.com'] }, + ]; + } + /** + * Check if a message contains potential scam links + */ + detectScamLinks(message) { + // Extract URLs from message + const urls = this.extractUrls(message); + if (urls.length === 0) { + return { isScam: false }; + } + for (const url of urls) { + const result = this.checkUrl(url); + if (result.isScam) { + return result; + } + } + return { isScam: false }; + } + /** + * Check a single URL for scam indicators + */ + checkUrl(url) { + try { + const parsed = new URL(url); + const domain = parsed.hostname.toLowerCase(); + // Check if domain is whitelisted + if (this.isWhitelisted(domain)) { + return { isScam: false }; + } + // Check for suspicious TLD + if (this.hasSuspiciousTLD(domain)) { + return { + isScam: true, + reason: 'Suspicious top-level domain often used for scams', + matchedPattern: domain + }; + } + // Check for typosquatting + const typosquatResult = this.checkTyposquatting(domain); + if (typosquatResult.isScam) { + return typosquatResult; + } + // Check for scam keywords in URL path + if (this.hasScamKeywords(url)) { + return { + isScam: true, + reason: 'URL contains keywords commonly used in scam campaigns', + matchedPattern: url + }; + } + // Check for suspicious URL patterns + if (this.hasSuspiciousPatterns(url)) { + return { + isScam: true, + reason: 'URL matches known scam patterns', + matchedPattern: url + }; + } + // Check for IP address URLs (often used in phishing) + if (this.isIpAddress(domain)) { + return { + isScam: true, + reason: 'URL uses IP address instead of domain name', + matchedPattern: domain + }; + } + return { isScam: false }; + } + catch (error) { + // Invalid URL, might be obfuscated + return { + isScam: true, + reason: 'Invalid or obfuscated URL format', + matchedPattern: url + }; + } + } + /** + * Extract URLs from text using regex + */ + extractUrls(text) { + const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/gi; + const matches = text.match(urlRegex); + return matches || []; + } + /** + * Check if domain is whitelisted + */ + isWhitelisted(domain) { + return this.WHITELISTED_DOMAINS.some(whitelisted => domain === whitelisted || domain.endsWith(`.${whitelisted}`)); + } + /** + * Check if domain has a suspicious TLD + */ + hasSuspiciousTLD(domain) { + return this.SUSPICIOUS_TLDS.some(tld => domain.endsWith(tld)); + } + /** + * Check for typosquatting patterns + */ + checkTyposquatting(domain) { + for (const { target, patterns } of this.TYPOSQUAT_PATTERNS) { + for (const pattern of patterns) { + if (domain === pattern || domain.endsWith(`.${pattern}`)) { + return { + isScam: true, + reason: `Possible typosquatting attempt mimicking ${target}`, + matchedPattern: domain + }; + } + } + } + return { isScam: false }; + } + /** + * Check if URL contains scam keywords + */ + hasScamKeywords(url) { + const lowerUrl = url.toLowerCase(); + // Check if multiple scam keywords are present (more suspicious) + const keywordCount = this.SCAM_KEYWORDS.filter(keyword => lowerUrl.includes(keyword)).length; + return keywordCount >= 2; // Require at least 2 scam keywords + } + /** + * Check for suspicious URL patterns + */ + hasSuspiciousPatterns(url) { + const lowerUrl = url.toLowerCase(); + // Check for excessive subdomains + const domain = new URL(url).hostname; + const subdomainCount = domain.split('.').length - 2; + if (subdomainCount > 3) { + return true; + } + // Check for random-looking strings (common in scam URLs) + const randomStringPattern = /[a-z0-9]{32,}/; + if (randomStringPattern.test(lowerUrl)) { + return true; + } + // Check for encoded characters (obfuscation) + if (/%[0-9a-f]{2}/.test(lowerUrl)) { + return true; + } + return false; + } + /** + * Check if domain is an IP address + */ + isIpAddress(domain) { + const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/; + return ipPattern.test(domain); + } + /** + * Add a domain to the whitelist + */ + addToWhitelist(domain) { + if (!this.WHITELISTED_DOMAINS.includes(domain)) { + this.WHITELISTED_DOMAINS.push(domain); + } + } + /** + * Remove a domain from the whitelist + */ + removeFromWhitelist(domain) { + const index = this.WHITELISTED_DOMAINS.indexOf(domain); + if (index > -1) { + this.WHITELISTED_DOMAINS.splice(index, 1); + } + } +} +exports.ScamDetectionService = ScamDetectionService; diff --git a/packages/bot/src/sessionManager.js b/packages/bot/src/sessionManager.js new file mode 100644 index 00000000..b2749ac5 --- /dev/null +++ b/packages/bot/src/sessionManager.js @@ -0,0 +1,154 @@ +"use strict"; +/** + * Bot Session Manager + * + * Manages bot session persistence by communicating with the backend API. + * This allows interactive bot sessions (wizards, multi-step flows) to survive bot restarts. + */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SessionManager = void 0; +class SessionManager { + constructor(backendUrl) { + this.backendUrl = backendUrl || process.env.BACKEND_URL || "http://localhost:3000"; + } + /** + * Create or update a bot session + */ + saveSession(data) { + return __awaiter(this, void 0, void 0, function* () { + try { + const response = yield fetch(`${this.backendUrl}/api/bot/session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + const result = yield response.json(); + return result; + } + catch (error) { + console.error('Error saving bot session:', error); + return { + success: false, + message: 'Failed to save session', + }; + } + }); + } + /** + * Get active session for a user + */ + getSession(userId, platform, sessionType) { + return __awaiter(this, void 0, void 0, function* () { + try { + const params = new URLSearchParams({ + userId, + platform, + sessionType, + }); + const response = yield fetch(`${this.backendUrl}/api/bot/session?${params}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const result = yield response.json(); + return result; + } + catch (error) { + console.error('Error getting bot session:', error); + return { + success: false, + message: 'Failed to get session', + }; + } + }); + } + /** + * Update an existing session + */ + updateSession(sessionId, updates) { + return __awaiter(this, void 0, void 0, function* () { + try { + const response = yield fetch(`${this.backendUrl}/api/bot/session/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updates), + }); + const result = yield response.json(); + return result; + } + catch (error) { + console.error('Error updating bot session:', error); + return { + success: false, + message: 'Failed to update session', + }; + } + }); + } + /** + * Deactivate a session + */ + deactivateSession(sessionId) { + return __awaiter(this, void 0, void 0, function* () { + try { + const response = yield fetch(`${this.backendUrl}/api/bot/session/${sessionId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + const result = yield response.json(); + return result; + } + catch (error) { + console.error('Error deactivating bot session:', error); + return { + success: false, + message: 'Failed to deactivate session', + }; + } + }); + } + /** + * Deactivate all sessions for a user + */ + deactivateUserSessions(userId, platform) { + return __awaiter(this, void 0, void 0, function* () { + try { + const url = platform + ? `${this.backendUrl}/api/bot/sessions/user/${userId}?platform=${platform}` + : `${this.backendUrl}/api/bot/sessions/user/${userId}`; + const response = yield fetch(url, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + const result = yield response.json(); + return result; + } + catch (error) { + console.error('Error deactivating user sessions:', error); + return { + success: false, + message: 'Failed to deactivate sessions', + }; + } + }); + } +} +exports.SessionManager = SessionManager; diff --git a/packages/sdk/src/__tests__/trustFramework.test.ts b/packages/sdk/src/__tests__/trustFramework.test.ts new file mode 100644 index 00000000..50bbf0ef --- /dev/null +++ b/packages/sdk/src/__tests__/trustFramework.test.ts @@ -0,0 +1,148 @@ +import { StellarTrustFramework, TrustStatus } from "../trustFramework"; +import { StellarMetadataManager } from "../metadata"; + +jest.mock("@stellar/stellar-sdk", () => { + const mockLoadAccount = jest.fn(); + const mockResolveToml = jest.fn(); + + return { + __esModule: true, + Horizon: { + Server: jest.fn(() => ({ loadAccount: mockLoadAccount })), + }, + StellarToml: { + Resolver: { + resolve: mockResolveToml, + }, + }, + __mockLoadAccount: mockLoadAccount, + __mockResolveToml: mockResolveToml, + }; +}); + +const { __mockLoadAccount: mockLoadAccount, __mockResolveToml: mockResolveToml } = + jest.requireMock("@stellar/stellar-sdk"); + +describe("StellarTrustFramework", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const validIssuer = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + it("returns VERIFIED when asset is listed in stellar.toml", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "example.com" }); + mockResolveToml.mockResolvedValueOnce({ CURRENCIES: [{ code: "USD", issuer: validIssuer }] }); + + const framework = new StellarTrustFramework({ enableScamDetection: true }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.status).toBe(TrustStatus.VERIFIED); + expect(result.isSafe).toBe(true); + expect(result.details).toContain("listed in stellar.toml"); + }); + + it("returns BLOCKED when issuer is in blocked issuers list", async () => { + const framework = new StellarTrustFramework({ blockedIssuers: [validIssuer] }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.status).toBe(TrustStatus.BLOCKED); + expect(result.isSafe).toBe(false); + expect(result.details).toContain("explicitly blocked"); + }); + + it("returns TRUSTED for safe asset even when stellar.toml is missing", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "example.com" }); + mockResolveToml.mockRejectedValueOnce(new Error("Not found")); + + const framework = new StellarTrustFramework({ safeAssets: [`USD:${validIssuer}`] }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.status).toBe(TrustStatus.TRUSTED); + expect(result.isSafe).toBe(true); + expect(result.details).toContain("Not found"); + }); + + it("returns BLOCKED when asset is in blocked assets list", async () => { + const framework = new StellarTrustFramework({ blockedAssets: [`USD:${validIssuer}`] }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.status).toBe(TrustStatus.BLOCKED); + expect(result.isSafe).toBe(false); + expect(result.details).toContain("explicitly blocked"); + }); + + it("returns MALICIOUS when issuer domain is blocked", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "malicious.example" }); + mockResolveToml.mockRejectedValueOnce(new Error("Not found")); + + const framework = new StellarTrustFramework({ blockedDomains: ["malicious.example"] }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.status).toBe(TrustStatus.MALICIOUS); + expect(result.isSafe).toBe(false); + expect(result.details).toContain("explicitly blocked"); + }); + + it("returns TRUSTED for safe issuer even when domain appears suspicious", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "free-airdrop.example" }); + mockResolveToml.mockRejectedValueOnce(new Error("Not found")); + + const framework = new StellarTrustFramework({ safeIssuers: [validIssuer], enableScamDetection: true }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.status).toBe(TrustStatus.TRUSTED); + expect(result.isSafe).toBe(true); + expect(result.details).toContain("Not found"); + }); + + it("blocks trustline execution when verified asset usage is required and asset is unverified", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "example.com" }); + mockResolveToml.mockRejectedValueOnce(new Error("Not found")); + + const framework = new StellarTrustFramework({ requireVerifiedAssetUsage: true }); + const gate = await framework.canExecuteTrustline("USD", validIssuer); + + expect(gate.allowed).toBe(false); + expect(gate.reason).toContain("Trustline creation is gated to verified assets only"); + expect(gate.trustResult.status).toBe(TrustStatus.UNVERIFIED); + }); + + it("blocks trustline execution when unverified asset usage is disabled", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "example.com" }); + mockResolveToml.mockRejectedValueOnce(new Error("Not found")); + + const framework = new StellarTrustFramework({ allowUnverifiedAssetUsage: false }); + const gate = await framework.canExecuteTrustline("USD", validIssuer); + + expect(gate.allowed).toBe(false); + expect(gate.reason).toContain("not permitted for unverified assets"); + expect(gate.trustResult.status).toBe(TrustStatus.UNVERIFIED); + }); + + it("attaches metadata from the metadata manager when available", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "example.com" }); + mockResolveToml.mockResolvedValueOnce({ CURRENCIES: [{ code: "USD", issuer: "GISSUER123" }] }); + + const metadataManager = { + getMetadata: jest.fn().mockResolvedValue({ value: JSON.stringify({ audit: "trusted" }), type: "json" }), + } as unknown as StellarMetadataManager; + + const framework = new StellarTrustFramework({ enableScamDetection: true, metadataManager }); + const result = await framework.verifyAsset("USD", validIssuer); + + expect(result.metadata).toEqual({ audit: "trusted" }); + expect(metadataManager.getMetadata).toHaveBeenCalled(); + }); + + it("blocks trustline execution for MALICIOUS domains", async () => { + mockLoadAccount.mockResolvedValueOnce({ home_domain: "free-airdrop.example" }); + mockResolveToml.mockRejectedValueOnce(new Error("Not found")); + + const framework = new StellarTrustFramework({ enableScamDetection: true }); + const gate = await framework.canExecuteTrustline("USD", validIssuer); + + expect(gate.allowed).toBe(false); + expect(gate.trustResult.status).toBe(TrustStatus.MALICIOUS); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3fdd3688..ef637d3d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,6 +5,7 @@ export * from "./signature-providers"; export * from "./soroban"; export * from "./events"; export * from "./trustline"; +export * from "./trustFramework"; export * from "./rateLimiter"; export * from "./planVerification"; export * from "./agentClient"; diff --git a/packages/sdk/src/trustFramework.ts b/packages/sdk/src/trustFramework.ts new file mode 100644 index 00000000..99a96623 --- /dev/null +++ b/packages/sdk/src/trustFramework.ts @@ -0,0 +1,398 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; +import { StellarMetadataManager } from "./metadata"; +import { resolveIssuerFromDomain } from "./trustline"; + +export enum TrustStatus { + VERIFIED = "VERIFIED", + TRUSTED = "TRUSTED", + UNVERIFIED = "UNVERIFIED", + MALICIOUS = "MALICIOUS", + BLOCKED = "BLOCKED", +} + +export interface AssetTrustResult { + assetCode: string; + issuer: string; + issuerDomain?: string; + status: TrustStatus; + isSafe: boolean; + details?: string; + metadata?: Record; +} + +export interface ExecutionGateResult { + allowed: boolean; + reason?: string; + trustResult: AssetTrustResult; +} + +export interface TrustPolicyConfig { + horizonUrl?: string; + allowUnverifiedAssetUsage?: boolean; + requireVerifiedAssetUsage?: boolean; + enableScamDetection?: boolean; + safeIssuers?: string[]; + blockedIssuers?: string[]; + safeAssets?: string[]; + blockedAssets?: string[]; + safeDomains?: string[]; + blockedDomains?: string[]; + metadataManager?: StellarMetadataManager; +} + +interface NormalizedTrustPolicyConfig extends TrustPolicyConfig { + allowUnverifiedAssetUsage: boolean; + requireVerifiedAssetUsage: boolean; + enableScamDetection: boolean; + safeIssuers: string[]; + blockedIssuers: string[]; + safeAssets: string[]; + blockedAssets: string[]; + safeDomains: string[]; + blockedDomains: string[]; +} + +export class StellarTrustFramework { + private server: StellarSdk.Horizon.Server; + private policy: NormalizedTrustPolicyConfig; + + constructor(config: TrustPolicyConfig = {}) { + this.server = new StellarSdk.Horizon.Server( + config.horizonUrl || "https://horizon.stellar.org" + ); + this.policy = { + horizonUrl: config.horizonUrl || "https://horizon.stellar.org", + allowUnverifiedAssetUsage: config.allowUnverifiedAssetUsage ?? false, + requireVerifiedAssetUsage: config.requireVerifiedAssetUsage ?? false, + enableScamDetection: config.enableScamDetection ?? true, + safeIssuers: (config.safeIssuers || []).map((id) => id.trim()), + blockedIssuers: (config.blockedIssuers || []).map((id) => id.trim()), + safeAssets: (config.safeAssets || []).map((key) => key.trim().toUpperCase()), + blockedAssets: (config.blockedAssets || []).map((key) => key.trim().toUpperCase()), + safeDomains: (config.safeDomains || []).map((domain) => domain.toLowerCase()), + blockedDomains: (config.blockedDomains || []).map((domain) => domain.toLowerCase()), + metadataManager: config.metadataManager, + }; + } + + private static normalizeIssuer(issuer: string): string { + return issuer.trim(); + } + + private static normalizeAssetKey(assetCode: string, issuer: string): string { + return `${assetCode.trim().toUpperCase()}:${issuer.trim()}`; + } + + private static isIssuerDomain(value: string): boolean { + return value.includes(".") && !value.toUpperCase().startsWith("G"); + } + + private static isValidStellarPublicKey(value: string): boolean { + return /^[G][A-Z0-9]{55}$/.test(value.trim()); + } + + private static isSuspiciousDomain(domain: string): boolean { + return /\b(free|bonus|giveaway|airdrop|claim|reward|loan|earn|wallet|verify|confirm|urgent|limited)\b/i.test( + domain + ); + } + + private async loadTrustMetadata( + issuerAddress: string, + assetCode: string + ): Promise | undefined> { + if (!this.policy.metadataManager) { + return undefined; + } + + try { + const metadataKey = `trust:asset:${assetCode.toUpperCase()}`; + const entry = await this.policy.metadataManager.getMetadata({ + accountId: issuerAddress, + key: metadataKey, + }); + if (!entry) { + return undefined; + } + + try { + return JSON.parse(entry.value); + } catch { + return { value: entry.value, type: entry.type } as Record; + } + } catch { + return undefined; + } + } + + private getStatusForSafeCandidate( + hasVerifiedListing: boolean, + isSafeAsset: boolean, + isSafeIssuer: boolean, + isSafeDomain: boolean + ): TrustStatus { + if (hasVerifiedListing) { + return TrustStatus.VERIFIED; + } + if (isSafeAsset || isSafeIssuer || isSafeDomain) { + return TrustStatus.TRUSTED; + } + return TrustStatus.UNVERIFIED; + } + + private getBlockedReason(assetCode: string, issuerAddress: string): string | undefined { + const assetKey = StellarTrustFramework.normalizeAssetKey(assetCode, issuerAddress); + if ((this.policy.blockedAssets ?? []).includes(assetKey)) { + return `Asset ${assetCode} from issuer ${issuerAddress} is explicitly blocked.`; + } + if ((this.policy.blockedIssuers ?? []).includes(issuerAddress)) { + return `Issuer ${issuerAddress} is explicitly blocked.`; + } + return undefined; + } + + private getDomainBlockReason( + domain?: string, + isSafeDomain: boolean = false, + isSafeAsset: boolean = false, + isSafeIssuer: boolean = false + ): string | undefined { + if (!domain) return undefined; + const normalized = domain.toLowerCase(); + if ((this.policy.blockedDomains ?? []).includes(normalized)) { + return `Domain ${domain} is explicitly blocked.`; + } + if ( + this.policy.enableScamDetection && + !isSafeDomain && + !isSafeAsset && + !isSafeIssuer && + StellarTrustFramework.isSuspiciousDomain(normalized) + ) { + return `Domain ${domain} contains suspicious terms commonly used by scam operators.`; + } + return undefined; + } + + private async resolveIssuerId(assetCode: string, issuerOrDomain: string): Promise { + const cleaned = StellarTrustFramework.normalizeIssuer(issuerOrDomain); + if (StellarTrustFramework.isIssuerDomain(cleaned)) { + return await resolveIssuerFromDomain(cleaned, assetCode); + } + return cleaned; + } + + public async verifyAsset( + assetCode: string, + issuerOrDomain: string + ): Promise { + const normalizedAssetCode = assetCode.trim().toUpperCase(); + const issuerInput = issuerOrDomain.trim(); + const issuerAddress = await this.resolveIssuerId(normalizedAssetCode, issuerInput); + + if (!issuerAddress) { + return { + assetCode: normalizedAssetCode, + issuer: issuerInput, + status: TrustStatus.UNVERIFIED, + isSafe: false, + details: `Could not resolve issuer for ${issuerInput}.`, + }; + } + + const normalizedInputDomain = issuerInput.toLowerCase(); + const inputIsDomain = StellarTrustFramework.isIssuerDomain(issuerInput); + const isSafeDomainInput = + inputIsDomain && (this.policy.safeDomains ?? []).includes(normalizedInputDomain); + const isBlockedDomainInput = + inputIsDomain && (this.policy.blockedDomains ?? []).includes(normalizedInputDomain); + + if (isBlockedDomainInput) { + return { + assetCode: normalizedAssetCode, + issuer: issuerAddress, + issuerDomain: issuerInput, + status: TrustStatus.MALICIOUS, + isSafe: false, + details: `Domain ${issuerInput} is explicitly blocked.`, + }; + } + + const blockedReason = this.getBlockedReason(normalizedAssetCode, issuerAddress); + if (blockedReason) { + return { + assetCode: normalizedAssetCode, + issuer: issuerAddress, + status: TrustStatus.BLOCKED, + isSafe: false, + details: blockedReason, + }; + } + + if (!StellarTrustFramework.isValidStellarPublicKey(issuerAddress)) { + return { + assetCode: normalizedAssetCode, + issuer: issuerAddress, + status: TrustStatus.UNVERIFIED, + isSafe: false, + details: `Issuer ${issuerAddress} is not a valid Stellar public key.`, + }; + } + + const safeAssetKey = StellarTrustFramework.normalizeAssetKey(normalizedAssetCode, issuerAddress); + const isSafeAsset = (this.policy.safeAssets ?? []).includes(safeAssetKey); + const isSafeIssuer = (this.policy.safeIssuers ?? []).includes(issuerAddress); + + let issuerDomain: string | undefined = isSafeDomainInput ? issuerInput : undefined; + let verifiedListing = false; + let tomlError: Error | undefined; + let scamReason: string | undefined; + let entryMetadata: Record | undefined; + let isSafeDomain = isSafeDomainInput; + + try { + const issuerAccount = await this.server.loadAccount(issuerAddress); + issuerDomain = issuerAccount.home_domain; + } catch { + if (!isSafeAsset && !isSafeIssuer && !isSafeDomain) { + return { + assetCode: normalizedAssetCode, + issuer: issuerAddress, + status: TrustStatus.UNVERIFIED, + isSafe: this.policy.allowUnverifiedAssetUsage ?? false, + details: "Issuer account could not be loaded.", + }; + } + } + + if (issuerDomain) { + isSafeDomain = Boolean( + issuerDomain && + (this.policy.safeDomains ?? []).includes(issuerDomain.toLowerCase()) + ); + const domainBlockReason = this.getDomainBlockReason( + issuerDomain, + isSafeDomain, + isSafeAsset, + isSafeIssuer + ); + if (domainBlockReason) { + return { + assetCode: normalizedAssetCode, + issuer: issuerAddress, + issuerDomain, + status: TrustStatus.MALICIOUS, + isSafe: false, + details: domainBlockReason, + }; + } + + try { + const toml = await StellarSdk.StellarToml.Resolver.resolve(issuerDomain); + const currencies: Record[] = toml.CURRENCIES || []; + verifiedListing = currencies.some( + (curr) => + String(curr.code).toUpperCase() === normalizedAssetCode && + String(curr.issuer) === issuerAddress + ); + } catch (error) { + tomlError = error as Error; + } + } + + if ( + this.policy.enableScamDetection && + !verifiedListing && + issuerDomain && + !isSafeDomain && + !isSafeAsset && + !isSafeIssuer + ) { + scamReason = StellarTrustFramework.isSuspiciousDomain(issuerDomain) + ? `Possible scam domain detected: ${issuerDomain}` + : undefined; + } + + if (issuerAddress && this.policy.metadataManager) { + entryMetadata = await this.loadTrustMetadata(issuerAddress, normalizedAssetCode); + } + + const status = this.getStatusForSafeCandidate( + verifiedListing, + isSafeAsset, + isSafeIssuer, + isSafeDomain + ); + + const details = verifiedListing + ? `Asset ${normalizedAssetCode} is listed in stellar.toml for ${issuerAddress}.` + : scamReason + ? scamReason + : tomlError + ? `Could not resolve stellar.toml for domain ${issuerDomain}: ${tomlError.message}` + : issuerDomain + ? `Asset ${normalizedAssetCode} is unverified for issuer ${issuerAddress}.` + : status === TrustStatus.TRUSTED + ? `Asset ${normalizedAssetCode} is considered trusted via safelist or issuer validation.` + : "Issuer home_domain is missing."; + + const isSafe = status === TrustStatus.VERIFIED || status === TrustStatus.TRUSTED; + + return { + assetCode: normalizedAssetCode, + issuer: issuerAddress, + issuerDomain, + status, + isSafe, + details, + metadata: entryMetadata, + }; + } + + public async canExecuteTrustline( + assetCode: string, + issuerOrDomain: string + ): Promise { + const trustResult = await this.verifyAsset(assetCode, issuerOrDomain); + + if ( + trustResult.status === TrustStatus.BLOCKED || + trustResult.status === TrustStatus.MALICIOUS + ) { + return { + allowed: false, + reason: trustResult.details || "This asset is explicitly blocked.", + trustResult, + }; + } + + if (trustResult.status === TrustStatus.UNVERIFIED) { + if (this.policy.requireVerifiedAssetUsage) { + return { + allowed: false, + reason: + "Trustline creation is gated to verified assets only. Please verify the issuer's stellar.toml registration before proceeding.", + trustResult, + }; + } + + if (!this.policy.allowUnverifiedAssetUsage) { + return { + allowed: false, + reason: + "Trustline creation is not permitted for unverified assets under current policy.", + trustResult, + }; + } + } + + return { + allowed: true, + trustResult, + reason: + trustResult.status === TrustStatus.UNVERIFIED + ? "Asset is unverified. Proceed with caution." + : undefined, + }; + } +} diff --git a/packages/sdk/src/trustline.ts b/packages/sdk/src/trustline.ts index 9d2b2cf5..35e47538 100644 --- a/packages/sdk/src/trustline.ts +++ b/packages/sdk/src/trustline.ts @@ -1,4 +1,4 @@ -// @ts-ignore: dependency is provided at the workspace root +// @ts-expect-error: dependency is provided at the workspace root import { Server, Asset, Operation } from "stellar-sdk"; export interface TrustlineCheckResult { @@ -69,7 +69,7 @@ export async function hasValidStellarTrustline( return { exists: true, authorized: true }; } - let account: any; + let account: Record; try { account = await server.accounts().accountId(accountId).call(); } catch (err) { @@ -80,8 +80,8 @@ export async function hasValidStellarTrustline( }; } - const balances: any[] = account.balances || []; - const match = balances.find((b) => { + const balances: Record[] = (account.balances as Record[]) || []; + const match = balances.find((b: Record) => { return ( b.asset_code === assetCode && (assetIssuer ? b.asset_issuer === assetIssuer : true) @@ -123,11 +123,11 @@ export async function findZeroBalanceTrustlines( ): Promise { const server = new Server(horizonUrl || "https://horizon.stellar.org"); const account = await server.accounts().accountId(accountId).call(); - const balances: any[] = account.balances || []; + const balances: Record[] = (account.balances as Record[]) || []; return balances - .filter((b) => b.asset_type !== "native" && parseFloat(b.balance) === 0) - .map((b) => ({ + .filter((b: Record) => b.asset_type !== "native" && parseFloat(b.balance as string) === 0) + .map((b: Record) => ({ assetCode: b.asset_code, assetIssuer: b.asset_issuer, balance: b.balance, @@ -142,12 +142,13 @@ export async function findZeroBalanceTrustlines( export function buildTrustlineRemovalOps( trustlines: TrustlineInfo[] ): Operation[] { - return trustlines.map((t) => + return trustlines.map((t: TrustlineInfo) => Operation.changeTrust({ asset: new Asset(t.assetCode, t.assetIssuer), limit: "0", }) ); +} /** * Creates a ChangeTrust operation for a given asset. * @@ -162,7 +163,7 @@ export async function createTrustlineOperation( assetIssuer: string, limit?: string, timeout?: number -): Promise { +): Promise { let issuer = assetIssuer; // If issuer looks like a domain, resolve it diff --git a/src/Agents/tools/sep1.ts b/src/Agents/tools/sep1.ts index b7a4cefc..3b505665 100644 --- a/src/Agents/tools/sep1.ts +++ b/src/Agents/tools/sep1.ts @@ -1,6 +1,7 @@ import { BaseTool } from "./base/BaseTool"; import { ToolMetadata, ToolResult } from "../registry/ToolMetadata"; import logger from "../../config/logger"; +import { StellarTrustFramework } from "../../../packages/sdk/src/trustFramework"; /** * SEP-1 Stellar.toml metadata structure @@ -101,9 +102,16 @@ export class Sep1Tool extends BaseTool { version: "1.0.0", }; + private trustFramework: StellarTrustFramework; + constructor() { super(); this.fetch = globalThis.fetch.bind(globalThis); + this.trustFramework = new StellarTrustFramework({ + enableScamDetection: true, + allowUnverifiedAssetUsage: true, + requireVerifiedAssetUsage: false, + }); } async execute(payload: AssetMetadataPayload): Promise { @@ -221,6 +229,13 @@ export class Sep1Tool extends BaseTool { }; } + let trustResult = undefined; + try { + trustResult = await this.trustFramework.verifyAsset(assetCode, assetIssuer); + } catch (error) { + logger.warn("SEP-1 trust evaluation failed:", error); + } + return { action: "sep1", status: "success", @@ -229,6 +244,7 @@ export class Sep1Tool extends BaseTool { issuer: assetIssuer, domain, ...currency, + trust: trustResult, }, message: `Retrieved metadata for ${assetCode} from ${domain}`, }; diff --git a/src/services/assetVerificationService.ts b/src/services/assetVerificationService.ts index a0be55fc..17d0eb33 100644 --- a/src/services/assetVerificationService.ts +++ b/src/services/assetVerificationService.ts @@ -1,72 +1,41 @@ -import * as StellarSdk from "@stellar/stellar-sdk"; - -export interface VerificationResult { - isSafe: boolean; - domain?: string; - status: "VERIFIED" | "UNVERIFIED" | "MALICIOUS"; - details?: string; -} +import { + StellarTrustFramework, + AssetTrustResult, + TrustPolicyConfig, + StellarMetadataManager, +} from "@chen-pilot/sdk-core"; + +const parseCsv = (value?: string): string[] => + value + ? value.split(",").map((entry) => entry.trim()).filter(Boolean) + : []; export class AssetVerificationService { - private horizonServer: StellarSdk.Horizon.Server; - - constructor(horizonUrl: string) { - this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + private trustFramework: StellarTrustFramework; + + constructor(horizonUrl: string, metadataManager?: StellarMetadataManager) { + const config: TrustPolicyConfig = { + horizonUrl, + enableScamDetection: true, + allowUnverifiedAssetUsage: false, + requireVerifiedAssetUsage: false, + safeIssuers: parseCsv(process.env.TRUST_SAFE_ISSUERS), + blockedIssuers: parseCsv(process.env.TRUST_BLOCKED_ISSUERS), + safeAssets: parseCsv(process.env.TRUST_SAFE_ASSETS), + blockedAssets: parseCsv(process.env.TRUST_BLOCKED_ASSETS), + safeDomains: parseCsv(process.env.TRUST_SAFE_DOMAINS), + blockedDomains: parseCsv(process.env.TRUST_BLOCKED_DOMAINS), + metadataManager, + }; + + this.trustFramework = new StellarTrustFramework(config); } - /** - * Requirement: Verify asset against home_domain and TOML files. - * Priority: High - */ - async verifyAsset( - assetCode: string, - issuerAddress: string - ): Promise { - try { - // 1. Fetch Issuer Account to get home_domain - const issuerAccount = await this.horizonServer.loadAccount(issuerAddress); - const homeDomain = issuerAccount.home_domain; - - if (!homeDomain) { - return { - isSafe: false, - status: "UNVERIFIED", - details: "No home_domain set on issuer account.", - }; - } - - // 2. Resolve and Fetch TOML (SEP-1) - const toml = await StellarSdk.StellarToml.Resolver.resolve(homeDomain); - - // 3. Verify Asset is listed in TOML - const verifiedAssets = toml.CURRENCIES || []; - const isListed = verifiedAssets.some( - (curr: Record) => - curr.code === assetCode && curr.issuer === issuerAddress - ); - - if (isListed) { - return { - isSafe: true, - domain: homeDomain, - status: "VERIFIED", - }; - } + async verifyAsset(assetCode: string, issuerAddress: string): Promise { + return this.trustFramework.verifyAsset(assetCode, issuerAddress); + } - return { - isSafe: false, - domain: homeDomain, - status: "MALICIOUS", - details: - "Asset issuer claims domain but asset is not listed in TOML file.", - }; - } catch (error) { - console.error("Asset verification error:", error); - return { - isSafe: false, - status: "UNVERIFIED", - details: "Verification failed due to network or TOML resolution error.", - }; - } + async canExecuteTrustline(assetCode: string, issuerAddress: string) { + return this.trustFramework.canExecuteTrustline(assetCode, issuerAddress); } }