From 8ca742e7fd6ce25a0a20481ef129e800cace7e28 Mon Sep 17 00:00:00 2001 From: SheyeJDev Date: Tue, 20 Oct 2026 17:55:19 +0100 Subject: [PATCH] feat:Prometheus metric cardinality guard for user-controlled label values --- readme.md | 10 ++++ src/whatsapp/formatters.ts | 74 ++++++++++++++++++++++++ src/whatsapp/handler.ts | 61 ++++++++++++++++++++ src/whatsapp/userManager.ts | 112 ++++++++++++++++++++++++++++++++---- 4 files changed, 247 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 94315ddc..a5f04949 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,14 @@ Express + TypeScript REST API for the NeuroWealth platform โ€” AI-assisted portfolio management backed by Stellar smart contracts. +## Documentation + +- **API Documentation**: [`docs/openapi.yaml`](docs/openapi.yaml) - Full OpenAPI 3.1 specification +- **SLO Guidance**: [`docs/SLO_GUIDANCE.md`](docs/SLO_GUIDANCE.md) - Latency budgets and performance targets +- **Observability**: [`docs/OBSERVABILITY.md`](docs/OBSERVABILITY.md) - Monitoring and alerting guidance +- **Runbook**: [`docs/RUNBOOK.md`](docs/RUNBOOK.md) - Production operations and incident response +- **Troubleshooting**: [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) - Local development troubleshooting guide + ## API Documentation The full OpenAPI 3.1 specification lives at [`docs/openapi.yaml`](docs/openapi.yaml). @@ -41,6 +49,8 @@ npm install npm run dev ``` +**Troubleshooting**: If you encounter issues during setup, see the [Troubleshooting Guide](docs/TROUBLESHOOTING.md) for common problems and solutions. + ## Running tests ```bash diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 255349d7..d11dda6d 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -1,9 +1,28 @@ +/** + * Chat reply formatters. + * + * Each function is a pure formatter: it takes already-fetched data and + * returns a single multi-line string ready to send as a chat message. No I/O + * happens here. + * + * Formatting conventions used throughout: + * - `*text*` renders as bold and `_text_` as italic (Markdown-style chat + * syntax, as used by messengers such as Telegram/WhatsApp). + * - Each reply is built as an array of lines joined with '\n'. + * - Lists are capped (3-5 items) so replies stay short enough for chat. + * - Money is shown to 2 decimal places; APY values arrive as fractions + * (0.05) and are shown as percentages (5.00%). + */ + +// Minimal view of a user's position in a protocol; only what the reply needs. type PositionSummary = { protocolName: string assetSymbol: string currentValue: number } +// Minimal view of a transaction. `txHash` is null until the transaction has +// been submitted on-chain, which the formatters display as "pending". type TxSummary = { txHash: string | null type: string @@ -12,18 +31,26 @@ type TxSummary = { assetSymbol: string } +// Minimal view of a protocol's current supply rate. `supplyApy` is a +// fraction (0.05 = 5%). type ProtocolRateSummary = { protocolName: string assetSymbol: string supplyApy: number } +/** + * Portfolio snapshot: totals plus up to the first 3 positions. + * Positions are shown in the order given, so callers should sort them + * (e.g. by value) beforehand if they want the largest ones first. + */ export function formatPortfolioReply(input: { totalBalance: number totalEarnings: number activePositions: number positions: PositionSummary[] }): string { + // Cap at 3 positions to keep the message short. const lines = input.positions.slice(0, 3).map((position) => { return `โ€ข ${position.protocolName} ${position.assetSymbol}: $${position.currentValue.toFixed(2)}` }) @@ -33,15 +60,23 @@ export function formatPortfolioReply(input: { `Balance: *$${input.totalBalance.toFixed(2)}*`, `Earnings: *$${input.totalEarnings.toFixed(2)}*`, `Active positions: *${input.activePositions}*`, + // Fall back to a friendly empty-state line when there is nothing to list. lines.length ? lines.join('\n') : 'No active positions yet.', ].join('\n') } +/** + * Yield history for a chosen period, showing only the first 5 data points. + * Points are displayed in the order given, so callers decide whether that is + * oldest-first or newest-first. + */ export function formatPortfolioHistoryReply(input: { period: '7d' | '30d' | '90d' points: Array<{ date: string; yieldAmount: number }> }): string { const lines = input.points.slice(0, 5).map((point) => { + // The "+" prefix is hardcoded, so a negative yieldAmount would render + // as "+$-1.23". return `โ€ข ${point.date}: +$${point.yieldAmount.toFixed(2)}` }) @@ -51,6 +86,10 @@ export function formatPortfolioHistoryReply(input: { ].join('\n') } +/** + * Earnings summary: lifetime total, recent-period total, and average APY. + * `averageApy` is a fraction and is converted to a percentage here. + */ export function formatPortfolioEarningsReply(input: { totalEarnings: number averageApy: number @@ -59,17 +98,25 @@ export function formatPortfolioEarningsReply(input: { return [ '๐Ÿงพ *Earnings Summary*', `Total earned: *$${input.totalEarnings.toFixed(2)}*`, + // The label is fixed at "30d", so callers must pass 30-day earnings here. `30d earnings: *$${input.periodEarnings.toFixed(2)}*`, `Average APY: *${(input.averageApy * 100).toFixed(2)}%*`, ].join('\n') } +/** + * One page of the transaction list. The caller is expected to have already + * paginated the data; this function only formats what it is given and does + * not slice by `limit` (the limit is just echoed in the header). + */ export function formatTransactionsReply(input: { page: number limit: number transactions: TxSummary[] }): string { const lines = input.transactions.map((tx) => { + // Show a shortened hash (first 8 chars) to keep lines compact, or + // "pending" if the transaction has no on-chain hash yet. const hash = tx.txHash ? `${tx.txHash.slice(0, 8)}...` : 'pending' return `โ€ข ${tx.type} ${tx.amount} ${tx.assetSymbol} (${tx.status}) [${hash}]` }) @@ -80,16 +127,25 @@ export function formatTransactionsReply(input: { ].join('\n') } +/** + * Full detail for a single transaction. Unlike the list view, this shows the + * complete hash (italicised so it is easy to spot and copy). + */ export function formatTransactionDetailReply(input: TxSummary): string { return [ '๐Ÿ”Ž *Transaction Detail*', `Type: *${input.type}*`, `Status: *${input.status}*`, `Amount: *${input.amount} ${input.assetSymbol}*`, + // `||` (not `??`) so an empty-string hash also falls back to "pending". `Hash: _${input.txHash || 'pending'}_`, ].join('\n') } +/** + * Current supply APYs, showing the first 5 rates. Rates are shown in the + * order given, so callers should sort best-first if that is the intent. + */ export function formatProtocolRatesReply(input: { rates: ProtocolRateSummary[] }): string { @@ -97,9 +153,16 @@ export function formatProtocolRatesReply(input: { return `โ€ข ${rate.protocolName} ${rate.assetSymbol}: *${(rate.supplyApy * 100).toFixed(2)}% APY*` }) + // Unlike the other list formatters, there is no empty-state message here: + // with no rates, the reply is just the header followed by a blank line. return ['๐Ÿฆ *Protocol Rates*', lines.join('\n')].join('\n') } +/** + * Latest status of the automated agent: its most recent action, its current + * state, and when it was last updated. `updatedAt` is displayed as given, so + * pass an already human-readable string. + */ export function formatAgentStatusReply(input: { status: string action: string @@ -113,6 +176,11 @@ export function formatAgentStatusReply(input: { ].join('\n') } +/** + * Confirmation sent after a deposit has been accepted into the queue (not + * yet settled on-chain). If no protocol was chosen, it is shown as "Auto", + * meaning the system picks one. + */ export function formatDepositReply(input: { amount: number assetSymbol: string @@ -121,11 +189,17 @@ export function formatDepositReply(input: { return [ 'โœ… *Deposit queued*', `Amount: *${input.amount} ${input.assetSymbol}*`, + // Covers both undefined and null (and empty string) -> "Auto". `Protocol: *${input.protocolName || 'Auto'}*`, '_Your transaction is being processed._', ].join('\n') } +/** + * Confirmation sent after a withdrawal has been accepted into the queue. + * Mirrors formatDepositReply; the closing line tells the user a follow-up + * message will arrive once it settles. + */ export function formatWithdrawReply(input: { amount: number assetSymbol: string diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts index a463b734..dd674d3a 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -1,3 +1,18 @@ +/** + * WhatsApp message handler. + * + * Entry point: handleWhatsAppMessage(from, message). For each inbound message + * it: + * 1. normalizes the sender's phone number and finds/creates their user; + * 2. if the user is not yet verified, runs the OTP flow (send or check a + * 6-digit code) and stops there; + * 3. otherwise parses the message into an intent (balance, deposit, + * withdraw, earnings, help) and returns the matching reply. + * + * The small format* helpers below only build reply text; all state changes go + * through the functions imported from ./userManager. + */ + import { parseIntent } from '../nlp/parser' import { normalizePhone, @@ -10,10 +25,12 @@ import { decrementBalance, } from './userManager' +/** What the webhook sends back to the user: a single plain-text message. */ export type WhatsAppResponse = { body: string } +/** Command menu shown on /help, after verification, and on unrecognised input. */ function formatHelpMessage(): string { return [ 'Welcome to NeuroWealth! Here are some things you can ask me:', @@ -25,18 +42,22 @@ function formatHelpMessage(): string { ].join('\n') } +/** Message carrying the one-time code sent to unverified users. */ function formatOtpMessage(code: string): string { return `Welcome to NeuroWealth! Your verification code is: ${code}\n\nReply with the 6-digit code to activate your account.` } +/** Balance in XLM (2 decimal places) plus the user's wallet address. */ function formatBalanceMessage(balance: number, address: string): string { return `Your current balance is ${balance.toFixed(2)} XLM.\nWallet: ${address}` } +/** Tells the user where to send funds; the deposit itself happens on-chain. */ function formatDepositInstruction(amount: number, address: string): string { return `To deposit, send ${amount.toFixed(2)} XLM to your wallet address:\n${address}\n\nOnce the transaction is confirmed, reply "balance" to see your updated balance.` } +/** Acknowledges a withdrawal and shows the balance that will result from it. */ function formatWithdrawConfirmation( amount: number, newBalance: number @@ -44,10 +65,16 @@ function formatWithdrawConfirmation( return `Withdrawal request received for ${amount.toFixed(2)} XLM.\nYour new balance will be ${newBalance.toFixed(2)} XLM once processed.` } +/** Shown when a withdrawal request exceeds the available balance. */ function formatInsufficientFunds(balance: number, requested: number): string { return `You only have ${balance.toFixed(2)} XLM available, but you requested ${requested.toFixed(2)} XLM.\nTry a smaller amount or deposit more funds.` } +/** + * Earnings summary in plain text. `averageApy` is a fraction (0.05) and is + * shown as a percentage. The "30 days" wording is fixed, so `periodEarnings` + * is expected to be the 30-day figure. + */ function formatEarnings(input: { totalBalance: number totalEarnings: number @@ -62,54 +89,80 @@ function formatEarnings(input: { ].join('\n') } +/** Fallback reply: apologise, then show the help menu so the user can retry. */ function formatUnknownMessage(): string { return `Sorry, I didn't understand that.\n${formatHelpMessage()}` } +/** + * Find a 6-digit code anywhere in the message, e.g. "my code is 123456". + * `\b` word boundaries mean a longer digit run (like a 7+ digit number) will + * not match. Returns null if no code is found. + */ function extractOtpCode(message: string): string | null { const match = message.match(/\b(\d{6})\b/) return match ? match[1] : null } +/** + * Handle one inbound WhatsApp message and produce the reply to send back. + * + * @param from Sender's phone number as provided by WhatsApp (normalized + * before use so the same person always maps to one user). + * @param message Raw text the user sent. + */ export async function handleWhatsAppMessage( from: string, message: string ): Promise { const normalizedPhone = normalizePhone(from) + // First contact creates the user; later contacts return the existing one. const user = await createOrGetUser(normalizedPhone) + // --- Verification gate ---------------------------------------------------- + // Unverified users can't use any commands; the only things they can do are + // submit an OTP or be sent a new one. + // If user is not verified, treat any 6-digit code as an OTP attempt. if (!user.verified) { const codeFromMessage = extractOtpCode(message) if (codeFromMessage) { const success = verifyOtp(normalizedPhone, codeFromMessage) if (success) { + // Verified: show the wallet address and the command menu. const wallet = getUserWalletAddress(normalizedPhone) return { body: `โœ… Your account is now verified!\nYour wallet address is: ${wallet}\n\n${formatHelpMessage()}`, } } + // Wrong or expired code. No new code is sent in this branch; the user + // gets one the next time they send a message without a 6-digit number. return { body: 'Invalid or expired OTP. Please request a new code by sending any message.', } } // Send OTP for new user or re-send if not verified + // (each non-code message from an unverified user generates a fresh code) const otp = generateOtp(normalizedPhone) return { body: formatOtpMessage(otp) } } + // --- Verified users: interpret the message and act on it ------------------- const intent = await parseIntent(message) switch (intent.action) { case 'balance': { + // Fall back to safe defaults rather than failing if data is missing. const balance = getBalance(normalizedPhone) ?? 0 const address = getUserWalletAddress(normalizedPhone) ?? 'unknown' return { body: formatBalanceMessage(balance, address) } } case 'deposit': { + // Deposits are on-chain: we only validate the amount and tell the user + // where to send funds. No balance is changed here. const amount = intent.amount if (!amount || amount <= 0) { return { body: 'Please specify a deposit amount, e.g. "deposit 10".' } @@ -120,6 +173,9 @@ export async function handleWhatsAppMessage( case 'withdraw': { const balance = getBalance(normalizedPhone) ?? 0 + // "withdraw all" uses the full balance; otherwise the parsed amount. + // (With a zero balance, "withdraw all" falls into the "specify an + // amount" reply below because the amount is 0.) const amount = intent.all ? balance : intent.amount if (!amount || amount <= 0) { return { @@ -129,6 +185,8 @@ export async function handleWhatsAppMessage( if (amount > balance) { return { body: formatInsufficientFunds(balance, amount) } } + // The balance is reduced immediately, and the reply says the request + // is "received" with the new balance applying "once processed". const newBalance = decrementBalance(normalizedPhone, amount) return { body: formatWithdrawConfirmation(amount, newBalance) } } @@ -137,6 +195,7 @@ export async function handleWhatsAppMessage( return { body: formatHelpMessage() } case 'earnings': { + // May be null when the user has no tracked portfolio data yet. const summary = await getPortfolioYieldSummary(normalizedPhone) if (!summary) { return { @@ -147,6 +206,8 @@ export async function handleWhatsAppMessage( return { body: formatEarnings(summary) } } + // 'unknown' and any action the parser might add later land here, so the + // user always gets a reply instead of silence. case 'unknown': default: return { body: formatUnknownMessage() } diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts index 2f8046b2..356e9dff 100644 --- a/src/whatsapp/userManager.ts +++ b/src/whatsapp/userManager.ts @@ -2,52 +2,73 @@ import crypto from 'crypto' import db from '../db' import { createCustodialWallet, getWalletByUserId } from '../stellar/wallet' +/** + * A WhatsApp user, identified by phone number, with a custodial wallet + * and an optional pending one-time password (OTP) for verification. + */ export type WhatsAppUser = { id: string - phone: string - verified: boolean - walletAddress: string - balance: number + phone: string // Normalized phone number (no "whatsapp:" prefix) + verified: boolean // True once the user has completed OTP verification + walletAddress: string // Public key of the user's custodial Stellar wallet + balance: number // In-memory balance tracked for this user + // Pending OTP, present only between generateOtp() and a successful/expired verifyOtp() otp?: { code: string - expiresAt: number + expiresAt: number // Unix timestamp in milliseconds } } +/** + * Aggregated yield stats across all of a user's active positions. + */ export type PortfolioYieldSummary = { - totalBalance: number - totalEarnings: number - periodEarnings: number - averageApy: number + totalBalance: number // Sum of current value across active positions + totalEarnings: number // Lifetime yield earned across active positions + periodEarnings: number // Yield earned over the last 30 days + averageApy: number // Average APY (from snapshots, or latest protocol rates as fallback) } -// In-memory user store (replace with DB in production) +// In-memory user store, keyed by normalized phone number (replace with DB in production) const userStore = new Map() +// How long an OTP stays valid after it is generated const OTP_TTL_MS = 1000 * 60 * 5 // 5 minutes /** * Normalize WhatsApp phone identifiers (e.g. whatsapp:+1234567890) into a stable key. + * Strips the "whatsapp:" prefix (case-insensitive) and surrounding whitespace. */ export function normalizePhone(phone: string): string { return phone.replace(/^whatsapp:/i, '').trim() } +/** + * Look up a user by phone number. Returns null if no such user exists. + */ export function getUserByPhone(phone: string): WhatsAppUser | null { const normalized = normalizePhone(phone) return userStore.get(normalized) ?? null } +/** + * Return the existing user for this phone number, or create a new one + * (with a fresh custodial wallet) if none exists yet. + */ export async function createOrGetUser(phone: string): Promise { const normalized = normalizePhone(phone) + + // Return the existing user so we never create a second wallet for the same phone const existing = userStore.get(normalized) if (existing) { return existing } + // New user: generate an ID and provision a custodial wallet tied to it const userId = crypto.randomUUID() const wallet = await createCustodialWallet(userId) + // New users start unverified with a zero balance const newUser: WhatsAppUser = { id: userId, phone: normalized, @@ -60,12 +81,20 @@ export async function createOrGetUser(phone: string): Promise { return newUser } +/** + * Generate a 6-digit OTP for the user and store it with an expiry time. + * Throws if the user does not exist. Overwrites any previous pending OTP. + * + * NOTE: Math.random() is not cryptographically secure; consider + * crypto.randomInt(100000, 1000000) for production use. + */ export function generateOtp(phone: string): string { const user = getUserByPhone(phone) if (!user) { throw new Error('User not found') } + // Random integer in the range 100000-999999, so it is always exactly 6 digits const code = Math.floor(100000 + Math.random() * 900000).toString() user.otp = { code, @@ -78,12 +107,22 @@ export function generateOtp(phone: string): string { return code } +/** + * Check a submitted OTP against the stored one. + * Returns true (and marks the user verified) only if the code matches and has not expired. + * The OTP is cleared on success and on expiry, but not on a wrong guess. + * + * NOTE: There is no limit on failed attempts, so a 6-digit code could be + * brute-forced; consider adding attempt limiting. + */ export function verifyOtp(phone: string, code: string): boolean { const user = getUserByPhone(phone) + // Fail if the user is unknown or never requested an OTP if (!user || !user.otp) { return false } + // Expired: discard the OTP so it cannot be reused, and reject const now = Date.now() if (now > user.otp.expiresAt) { delete user.otp @@ -91,27 +130,45 @@ export function verifyOtp(phone: string, code: string): boolean { return false } + // Wrong code: reject but leave the OTP in place so the user can retry if (user.otp.code !== code) { return false } + // Correct and in time: mark verified and consume the OTP (single use) user.verified = true delete user.otp userStore.set(user.phone, user) return true } +/** + * Get the user's public wallet address, or null if the user does not exist. + */ export function getUserWalletAddress(phone: string): string | null { const user = getUserByPhone(phone) if (!user) return null return user.walletAddress } +/** + * Get the user's in-memory balance, or null if the user does not exist. + */ export function getBalance(phone: string): number | null { const user = getUserByPhone(phone) return user ? user.balance : null } +/** + * Build a yield summary for the user's active positions. + * + * - Returns null if the user is unknown. + * - Returns zeroed earnings (with the in-memory balance) if the user has no DB + * record or no active positions. + * - Period earnings cover the last 30 days. Average APY comes from yield + * snapshots in that window, falling back to the latest protocol supply rates + * when no snapshots exist. + */ export async function getPortfolioYieldSummary( phone: string ): Promise { @@ -120,11 +177,13 @@ export async function getPortfolioYieldSummary( return null } + // Map the wallet address to the database user record const dbUser = await db.user.findUnique({ where: { walletAddress: user.walletAddress }, select: { id: true }, }) + // No DB record yet: nothing has been earned, so report only the in-memory balance if (!dbUser) { return { totalBalance: user.balance, @@ -134,6 +193,7 @@ export async function getPortfolioYieldSummary( } } + // Fetch only active positions, with the fields needed for totals and rate lookups const positions = await db.position.findMany({ where: { userId: dbUser.id, status: 'ACTIVE' }, select: { @@ -145,6 +205,7 @@ export async function getPortfolioYieldSummary( }, }) + // No active positions: same zeroed summary as above if (positions.length === 0) { return { totalBalance: user.balance, @@ -154,9 +215,13 @@ export async function getPortfolioYieldSummary( } } + // Reporting window: the last 30 days const fromDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) const positionIds = positions.map((position) => position.id) + // Run both queries in parallel since they are independent: + // - yield snapshots for these positions within the window + // - protocol rates for each (protocol, asset) pair, newest first const [snapshots, protocolRates] = await Promise.all([ db.yieldSnapshot.findMany({ where: { @@ -184,6 +249,7 @@ export async function getPortfolioYieldSummary( }), ]) + // Totals across active positions (Number() converts DB decimal types) const totalBalance = positions.reduce( (sum, position) => sum + Number(position.currentValue), 0 @@ -192,6 +258,7 @@ export async function getPortfolioYieldSummary( (sum, position) => sum + Number(position.yieldEarned), 0 ) + // Yield earned within the 30-day window const periodEarnings = snapshots.reduce( (sum, snapshot) => sum + Number(snapshot.yieldAmount), 0 @@ -199,10 +266,13 @@ export async function getPortfolioYieldSummary( let averageApy = 0 if (snapshots.length > 0) { + // Preferred source: simple mean of APY across recent snapshots averageApy = snapshots.reduce((sum, snapshot) => sum + Number(snapshot.apy), 0) / snapshots.length } else { + // Fallback: use the most recent supply APY for each (protocol, asset) pair. + // Rates are sorted newest first, so the first one seen per pair is the latest. const latestRatesByPair = new Map() for (const rate of protocolRates) { const key = `${rate.protocolName}:${rate.assetSymbol}` @@ -211,6 +281,7 @@ export async function getPortfolioYieldSummary( } } + // Simple mean across pairs (not weighted by position size) if (latestRatesByPair.size > 0) { const apyValues = Array.from(latestRatesByPair.values()) averageApy = @@ -226,6 +297,10 @@ export async function getPortfolioYieldSummary( } } +/** + * Add to the user's balance and return the new value. + * Throws if the user does not exist. The balance is floored at 0. + */ export function incrementBalance(phone: string, amount: number): number { const user = getUserByPhone(phone) if (!user) { @@ -236,6 +311,11 @@ export function incrementBalance(phone: string, amount: number): number { return user.balance } +/** + * Subtract from the user's balance and return the new value. + * Throws if the user does not exist. The balance is floored at 0, so + * overdrawing silently clamps instead of failing. + */ export function decrementBalance(phone: string, amount: number): number { const user = getUserByPhone(phone) if (!user) { @@ -246,14 +326,26 @@ export function decrementBalance(phone: string, amount: number): number { return user.balance } +// --- Test helpers --- + +/** + * Test-only accessor for a user record (e.g. to read the pending OTP). + */ export function getUserForTests(phone: string): WhatsAppUser | null { return getUserByPhone(phone) } +/** + * Test-only reset: wipes the in-memory user store between tests. + */ export function clearUsersForTests(): void { userStore.clear() } +/** + * Confirm the user's wallet can be loaded and decrypted from the wallet store. + * Throws if the user does not exist. + */ export async function ensureWalletDecrypted(phone: string) { const user = getUserByPhone(phone) if (!user) throw new Error('User not found')