diff --git a/readme.md b/readme.md new file mode 100644 index 00000000..56909883 --- /dev/null +++ b/readme.md @@ -0,0 +1,142 @@ +# NeuroWealth Backend + +[![CI](https://github.com/Neurowealth/Backend/actions/workflows/node-ci.yml/badge.svg)](https://github.com/Neurowealth/Backend/actions/workflows/node-ci.yml) +[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE) +[![Node.js](https://img.shields.io/badge/Node.js-22%2B-green.svg)](https://nodejs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue.svg)](https://www.typescriptlang.org/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +> Autonomous AI investment agent backend that manages and grows crypto assets on the **Stellar blockchain**. Users deposit once and the AI finds yield opportunities across Stellar's DeFi ecosystem. + +## Features + +- **AI-Powered Portfolio Management** - Autonomous rebalancing using Anthropic Claude +- **Stellar Blockchain Integration** - Native Soroban smart contracts, claimable balances, and multi-sig support +- **DeFi Yield Optimization** - Automated yield farming across Stellar protocols +- **Real-time Analytics** - Monte Carlo simulations, correlation analysis, factor exposure, stress testing +- **NLP Trading Interface** - Natural language commands for portfolio management (Telegram, WhatsApp) +- **Circuit Breaker System** - Automated risk management with exposure caps and rebalance cost analysis +- **Privacy & Compliance** - GDPR/CCPA erasure, Travel Rule support, audit trail +- **Tax Reporting** - Multi-jurisdiction tax calculation (US, UK, DE, CA, AU) +- **Referral Program** - Multi-tier referral system with fraud detection + +## Quickstart + +```bash +# 1. Clone and install +git clone https://github.com/Neurowealth/Backend.git +cd Backend +npm install + +# 2. Configure environment +cp .env.example .env +# Edit .env with your values +# REDIS_URL defaults to redis://localhost:6379 (provided by docker-compose) + +# 3. Start database and Redis +docker-compose up -d + +# 4. Run migrations +npx prisma migrate deploy + +# 5. Start development server +npm run dev +``` + +## Available Scripts + +| Script | Description | +|--------|-------------| +| `npm run dev` | Start development server with hot-reload | +| `npm test` | Run all tests | +| `npm run test:unit` | Run unit tests only | +| `npm run test:integration` | Run integration tests | +| `npm run test:coverage` | Run tests with coverage report | +| `npm run lint` | Run ESLint | +| `npm run typecheck` | Run TypeScript type check | +| `npm run build` | Build for production | +| `npm run format` | Format code with Prettier | +| `npm run validate:spec` | Validate OpenAPI specification | + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Runtime | Node.js 22+ / TypeScript 5.3 | +| Framework | Express.js | +| Database | PostgreSQL 14+ (Prisma ORM) | +| Cache/Queue | Redis (ioredis) | +| Blockchain | Stellar SDK, Soroban smart contracts | +| AI | Anthropic Claude SDK | +| Monitoring | OpenTelemetry, Prometheus, Sentry | +| Security | Helmet, CORS, JWT, Zod validation | +| Docs | Swagger UI, OpenAPI (Redocly-validated) | +| Testing | Jest, Supertest | +| CI/CD | GitHub Actions, Dependabot | +| Deployment | Docker, Kubernetes | + +## Project Structure + +``` +Backend/ +├── src/ +│ ├── agent/ # AI agent, strategies, risk management +│ ├── analytics/ # Monte Carlo, correlation, factor analysis +│ ├── auth/ # JWT authentication, API keys +│ ├── compliance/ # Travel Rule, KYC support +│ ├── controllers/ # Request handlers +│ ├── fiat/ # Fiat on-ramp (Transak) +│ ├── jobs/ # Background jobs (erasure, treasury, reconciliation) +│ ├── middleware/ # Auth, rate limiting, sub-accounts +│ ├── nlp/ # Natural language processing +│ ├── outbox/ # Transactional outbox pattern +│ ├── privacy/ # Data mapping, erasure policies +│ ├── referral/ # Multi-tier referral system +│ ├── routes/ # API route definitions +│ ├── stellar/ # Stellar SDK integration +│ ├── strategy/ # Strategy simulation +│ ├── tax/ # Multi-jurisdiction tax reporting +│ ├── telegram/ # Telegram bot integration +│ ├── whatsapp/ # WhatsApp bot integration +│ └── validators/ # Zod request validation +├── tests/ +│ ├── unit/ # Unit tests +│ ├── integration/ # Integration tests +│ └── load/ # Load tests +├── prisma/ # Database schema & migrations +├── docs/ # Documentation (38+ files) +├── deploy/ # Kubernetes & monitoring configs +└── scripts/ # Operational scripts +``` + +## Documentation + +| Document | Description | +|----------|-------------| +| [Documentation Index](docs/DOCUMENTATION_INDEX.md) | Master index with reading order | +| [API Reference](docs/API_REFERENCE.md) | Full endpoint reference | +| [Deployment Guide](docs/DEPLOYMENT.md) | Local, staging, production setup | +| [Runbook](docs/RUNBOOK.md) | Operational runbook | +| [OpenAPI Spec](docs/openapi.yaml) | Machine-readable API specification | +| [Contributing](CONTRIBUTING.md) | How to contribute | +| [Security Policy](SECURITY.md) | Vulnerability reporting & security measures | + +## Environment Variables + +See [`.env.example`](.env.example) for all required environment variables. + +Key variables: +- `DATABASE_URL` - PostgreSQL connection string +- `STELLAR_NETWORK` - `testnet` or `mainnet` +- `STELLAR_AGENT_SECRET_KEY` - Stellar secret key +- `VAULT_CONTRACT_ID` - Deployed vault contract ID +- `JWT_SECRET` - JWT signing secret +- `ANTHROPIC_API_KEY` - Claude AI API key + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +This project is licensed under the ISC License - see the [LICENSE](LICENSE) file for details. diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 174d1ac8..06ad31f7 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -1,9 +1,29 @@ +/** + * 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%). The exception is + * formatGoalProgressReply, which prints its APY values as given. + */ + +// 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 +32,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 +61,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 +87,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 +99,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 +128,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 +154,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 +177,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 +190,23 @@ 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') } +/** + * Savings-goal progress. The reply depends on the goal's status: + * - ACHIEVED / MISSED / CANCELLED: a short terminal message. + * - anything else (active): progress, target date, required vs actual APY, + * an on-track / behind / unreachable line, and a projected completion date + * when one is available. + * + * Dates are expected as ISO strings and are trimmed to YYYY-MM-DD. Unlike the + * other formatters, `requiredApy` and `actualApy` are printed as given with a + * "%" sign (no ×100 conversion), so pass them as percentages, not fractions. + */ export function formatGoalProgressReply(input: { status: string targetAmount: number @@ -158,10 +239,12 @@ export function formatGoalProgressReply(input: { const lines = [ '🎯 *Savings Goal Progress*', `Progress: *$${input.currentAmount.toFixed(2)}* of *$${input.targetAmount.toFixed(2)}*`, + // slice(0, 10) keeps just the date part of an ISO timestamp. `Target date: _${input.targetDate.slice(0, 10)}_`, `Required APY: *${input.requiredApy.toFixed(2)}%* | Actual APY: *${input.actualApy.toFixed(2)}%*`, ] + // Exactly one status line: not reachable, on track, or behind but reachable. if (!input.reachable) { lines.push('⚠️ Target not reachable within your risk tolerance.') } else if (input.onTrack) { @@ -179,6 +262,11 @@ export function formatGoalProgressReply(input: { return lines.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 ba1e16f2..206cbbd5 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -1,5 +1,25 @@ +/** + * WhatsApp message handler. + * + * Entry point: handleWhatsAppMessage(from, message, media?). 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. if the message is a voice note, transcribes it (asking the user to + * repeat or type if that fails or confidence is low); + * 4. resolves any pending yes/no confirmation for a voice-originated + * financial action; + * 5. otherwise parses the text into an intent (balance, deposit, withdraw, + * earnings, goal, recurring deposits, alerts, help) and returns the + * matching reply, optionally handing unrecognised requests to the + * tool-calling assistant. + * + * The small format* helpers below (and those in ./formatters) only build reply + * text. + */ + import { parseIntent, type Intent } from '../nlp/parser' -import { formatGoalProgressReply } from './formatters' import { normalizePhone, createOrGetUser, @@ -21,6 +41,7 @@ import { formatAlertCreatedReply, formatAlertListReply, formatAlertDeletedReply, + formatGoalProgressReply, } from './formatters' import { logger } from '../utils/logger' import { config } from '../config' @@ -37,6 +58,7 @@ import { } from './pendingConfirmations' import { handleAssistantMessage } from '../agent/assistant/assistant' +/** What the webhook sends back to the user: a single plain-text message. */ export type WhatsAppResponse = { body: string } @@ -50,6 +72,7 @@ export type InboundMedia = { contentType: 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:', @@ -66,18 +89,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 @@ -85,10 +112,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 @@ -103,57 +136,21 @@ 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 } -/** - * Financial / strategy-changing intents. When one of these originates from a - * voice note it must be confirmed before execution (#288) — misrecognition on - * a fund movement is materially worse than on a read-only query. - */ -const FINANCIAL_ACTIONS: ReadonlySet = new Set([ - 'deposit', - 'withdraw', -]) - -function isFinancialIntent(intent: Intent): boolean { - return FINANCIAL_ACTIONS.has(intent.action) -} - -/** Affirmative reply to a pending confirmation ("yes", "confirm", "yeah"…). */ -export function isAffirmative(message: string): boolean { - return /^\s*(yes|yep|yeah|yup|confirm|confirmed|ok|okay|sure|correct|do it|go ahead|proceed|y)\s*[.!]*\s*$/i.test( - message - ) -} - -/** Negative reply to a pending confirmation ("no", "cancel", "stop"…). */ -export function isNegative(message: string): boolean { - return /^\s*(no|nope|nah|cancel|stop|abort|don'?t|never mind|nevermind|n)\s*[.!]*\s*$/i.test( - message - ) -} - -/** Human-readable echo of a financial intent for the confirmation prompt. */ -export function summarizeIntent(intent: Intent): string { - switch (intent.action) { - case 'deposit': - return `deposit ${intent.amount ?? ''}`.trim() - case 'withdraw': - return intent.all - ? 'withdraw all' - : `withdraw ${intent.amount ?? ''}`.trim() - default: - return intent.action - } -} - /** * Execute a parsed intent and produce the reply. This is the single place that * acts on an intent, shared by typed messages, confirmed voice commands, and @@ -165,12 +162,15 @@ async function executeIntent( ): Promise { 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".' } @@ -181,6 +181,9 @@ async function executeIntent( 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 { @@ -190,6 +193,8 @@ async function executeIntent( 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) } } @@ -208,6 +213,7 @@ async function executeIntent( 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 { @@ -218,6 +224,7 @@ async function executeIntent( } case 'create_recurring_deposit': { + // Both an amount and a schedule are required before creating a plan. const amount = intent.amount const cadence = intent.cadence if (!amount || amount <= 0) { @@ -230,6 +237,7 @@ async function executeIntent( body: 'Please specify a schedule: weekly, biweekly, or monthly.', } } + // Map the phone to a DB user via the wallet address. const wallet = getUserWalletAddress(normalizedPhone) if (!wallet) { return { body: 'Your account is not fully set up yet.' } @@ -241,6 +249,7 @@ async function executeIntent( if (!user) { return { body: 'Your account is not fully set up yet.' } } + // First run is one full interval from now (not immediately). const nextRunAt = new Date() switch (cadence) { case 'WEEKLY': @@ -291,6 +300,7 @@ async function executeIntent( if (activePlans.length === 0) { return { body: 'You have no active recurring deposits to pause.' } } + // Pauses every active plan at once, not just one. await db.recurringDepositPlan.updateMany({ where: { userId: user.id, status: 'ACTIVE' }, data: { status: 'PAUSED' }, @@ -346,8 +356,11 @@ async function executeIntent( } case 'clarification': + // The parser needs more information; relay its follow-up question. return { body: intent.prompt } + // '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() } @@ -476,14 +489,27 @@ function mediaErrorReply(err: unknown): string { return "Voice messages aren't available right now. Please type your command." } +/** + * 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 (may be empty for a voice note). + * @param media Voice note or other attachment, if the message has one. + */ export async function handleWhatsAppMessage( from: string, message: string, media?: InboundMedia ): 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. // OTP is text-only; a voice note here still triggers the resend path below. if (!user.verified) { @@ -491,18 +517,22 @@ export async function handleWhatsAppMessage( 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) } } diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts index 556a36a6..0f6e6b71 100644 --- a/src/whatsapp/userManager.ts +++ b/src/whatsapp/userManager.ts @@ -7,52 +7,73 @@ import { GoalProgress, } from '../goals/service' +/** + * 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, @@ -65,12 +86,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, @@ -83,12 +112,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 @@ -96,27 +135,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 { @@ -125,11 +182,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, @@ -139,6 +198,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: { @@ -150,6 +210,7 @@ export async function getPortfolioYieldSummary( }, }) + // No active positions: same zeroed summary as above if (positions.length === 0) { return { totalBalance: user.balance, @@ -159,9 +220,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: { @@ -189,6 +254,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 @@ -197,6 +263,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 @@ -204,10 +271,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}` @@ -216,6 +286,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 = @@ -270,6 +341,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) { @@ -280,14 +356,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')