diff --git a/backend/.env.example b/backend/.env.example index 6b85f08a..af892609 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -60,6 +60,8 @@ CREDIT_SCORE_CACHE_TTL_SECONDS=300 # Withdrawals WITHDRAWAL_MIN_AMOUNT_STROOPS=10000000 +# Withdrawal fee, in basis points (1/100th of a percent). 200 = 2%. +WITHDRAWAL_FEE_BPS=200 # X (Twitter) API — credit score signals X_API_BEARER_TOKEN= diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 90e92cbd..cc5cc581 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,18 +15,18 @@ datasource db { /// A creator/user identified by their Stellar wallet address. /// Extended by credit, tips and other modules in later issues. model User { - id String @id @default(cuid()) - stellarAddress String @unique - username String? @unique - displayName String? - bio String? - imageUrl String? - avatarCid String? - xHandle String? - role String @default("user") - scopes String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + stellarAddress String @unique + username String? @unique + displayName String? + bio String? + imageUrl String? + avatarCid String? + xHandle String? + role String @default("user") + scopes String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt /// Soft-delete marker: non-null means the record is logically deleted. deletedAt DateTime? apiKeys ApiKey[] diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index ccc89108..9e01c8a4 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -61,6 +61,8 @@ const envSchema = z.object({ CREDIT_SCORE_CACHE_TTL_SECONDS: z.coerce.number().int().positive().optional(), /** Minimum withdrawal amount, in stroops (1 XLM = 10,000,000 stroops). */ WITHDRAWAL_MIN_AMOUNT_STROOPS: z.coerce.number().int().positive().default(10_000_000), + /** Withdrawal fee, in basis points (1/100th of a percent). 200 = 2%. */ + WITHDRAWAL_FEE_BPS: z.coerce.number().int().min(0).max(10_000).default(200), X_API_BEARER_TOKEN: z.string().optional(), X_API_BASE_URL: z.string().default('https://api.twitter.com/2'), diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3a9afe60..a411a354 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -58,6 +58,11 @@ export const config = { recomputeCron: env.CREDIT_RECOMPUTE_CRON, }, + withdrawals: { + minAmountStroops: env.WITHDRAWAL_MIN_AMOUNT_STROOPS, + feeBps: env.WITHDRAWAL_FEE_BPS, + }, + logging: { level: env.LOG_LEVEL, sentryDsn: env.SENTRY_DSN, diff --git a/backend/src/modules/tips/tips.controller.ts b/backend/src/modules/tips/tips.controller.ts index c74c2fa1..e6a8e078 100644 --- a/backend/src/modules/tips/tips.controller.ts +++ b/backend/src/modules/tips/tips.controller.ts @@ -9,7 +9,10 @@ import { confirmTipParamSchema, } from './tips.schema.js'; import * as tipsService from './tips.service.js'; -import { emitTipCreated } from '../../realtime/index.js'; +import { emitTipCreated, emitBalanceUpdated } from '../../realtime/index.js'; +import { prisma } from '../../db/prisma.js'; +import { getWithdrawableBalance } from '../withdrawals/withdrawals.service.js'; +import { logger } from '../../common/utils/logger.js'; /** GET /tips — filterable, cursor-paginated list of tips. */ export async function getTips(req: Request, res: Response, next: NextFunction): Promise { @@ -85,6 +88,20 @@ export async function confirm(req: Request, res: Response, next: NextFunction): try { const { txHash } = confirmTipParamSchema.parse(req.params); const tip = await tipsService.confirmTip(txHash); + + // Confirming a tip changes the recipient's withdrawable balance; notify + // their sockets. Best-effort — a failure here must not turn an already + // successful confirmation into an error response. + try { + const recipient = await prisma.user.findUnique({ where: { stellarAddress: tip.toAddress } }); + if (recipient) { + const balance = await getWithdrawableBalance(recipient.id); + emitBalanceUpdated({ userId: recipient.id, ...balance }); + } + } catch (err) { + logger.error({ err, txHash }, 'Failed to emit balance.updated after tip confirmation'); + } + res.status(200).json({ data: tip }); } catch (err) { next(err); diff --git a/backend/src/modules/tips/tips.test.ts b/backend/src/modules/tips/tips.test.ts index 3028c5d8..e0cdde65 100644 --- a/backend/src/modules/tips/tips.test.ts +++ b/backend/src/modules/tips/tips.test.ts @@ -14,8 +14,9 @@ const { mockCreate, mockUpdate, mockGroupBy, - mockUserFindUnique, - mockCreateNotification, + mockFindUniqueUser, + mockEmitBalanceUpdated, + mockGetWithdrawableBalance, } = vi.hoisted(() => ({ mockGetAccount: vi.fn(), mockSimulateTransaction: vi.fn(), @@ -27,8 +28,18 @@ const { mockCreate: vi.fn(), mockUpdate: vi.fn(), mockGroupBy: vi.fn(), - mockUserFindUnique: vi.fn(), - mockCreateNotification: vi.fn(), + mockFindUniqueUser: vi.fn(), + mockEmitBalanceUpdated: vi.fn(), + mockGetWithdrawableBalance: vi.fn(), +})); + +vi.mock('../../realtime/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, emitBalanceUpdated: mockEmitBalanceUpdated }; +}); + +vi.mock('../withdrawals/withdrawals.service.js', () => ({ + getWithdrawableBalance: mockGetWithdrawableBalance, })); vi.mock('@stellar/stellar-sdk', () => { @@ -101,7 +112,7 @@ vi.mock('../../db/prisma.js', () => ({ groupBy: mockGroupBy, }, user: { - findUnique: mockUserFindUnique, + findUnique: mockFindUniqueUser, }, $disconnect: vi.fn(), }, @@ -732,6 +743,45 @@ describe('PATCH /api/v1/tips/:txHash/confirm', () => { const res = await request(app).patch('/api/v1/tips//confirm'); expect(res.status).toBe(404); }); + + it('emits balance.updated for the recipient after confirming (#951)', async () => { + mockFindUnique.mockResolvedValue(pendingRow); + mockUpdate.mockResolvedValue(confirmedRow); + mockFindUniqueUser.mockResolvedValue({ id: 'user-1', stellarAddress: to }); + mockGetWithdrawableBalance.mockResolvedValue({ + stellarAddress: to, + totalReceived: '1000000', + totalWithdrawn: '0', + withdrawableBalance: '1000000', + }); + + const app = createApp(); + const res = await request(app).patch(`/api/v1/tips/${txHash}/confirm`); + + expect(res.status).toBe(200); + expect(mockFindUniqueUser).toHaveBeenCalledWith({ where: { stellarAddress: to } }); + expect(mockGetWithdrawableBalance).toHaveBeenCalledWith('user-1'); + expect(mockEmitBalanceUpdated).toHaveBeenCalledWith({ + userId: 'user-1', + stellarAddress: to, + totalReceived: '1000000', + totalWithdrawn: '0', + withdrawableBalance: '1000000', + }); + }); + + it('does not emit balance.updated when the recipient has no account', async () => { + mockFindUnique.mockResolvedValue(pendingRow); + mockUpdate.mockResolvedValue(confirmedRow); + mockFindUniqueUser.mockResolvedValue(null); + + const app = createApp(); + const res = await request(app).patch(`/api/v1/tips/${txHash}/confirm`); + + expect(res.status).toBe(200); + expect(mockGetWithdrawableBalance).not.toHaveBeenCalled(); + expect(mockEmitBalanceUpdated).not.toHaveBeenCalled(); + }); }); // ── OpenAPI docs registration ─────────────────────────────────────────────── diff --git a/backend/src/modules/withdrawals/withdrawals.service.ts b/backend/src/modules/withdrawals/withdrawals.service.ts index 083a8807..37db5ce9 100644 --- a/backend/src/modules/withdrawals/withdrawals.service.ts +++ b/backend/src/modules/withdrawals/withdrawals.service.ts @@ -56,10 +56,35 @@ export async function getWithdrawableBalance(userId: string): Promise ({ @@ -179,6 +180,36 @@ describe('POST /api/v1/withdrawals/prepare', () => { unsignedTxXdr: 'AAAAAgAAAAA...mock-unsigned-xdr...', destination: address, amount: '1000000', + fee: '20000', + netAmount: '980000', }); }); }); + +describe('calculateWithdrawalFee', () => { + it('charges a 2% fee (the default rate) rounded down', () => { + expect(calculateWithdrawalFee(BigInt(1_000_000), 200)).toEqual({ + fee: BigInt(20_000), + netAmount: BigInt(980_000), + }); + }); + + it('floors the fee instead of rounding up', () => { + expect(calculateWithdrawalFee(BigInt(999), 200)).toEqual({ + fee: BigInt(19), + netAmount: BigInt(980), + }); + }); + + it('supports a zero fee rate', () => { + expect(calculateWithdrawalFee(BigInt(1_000_000), 0)).toEqual({ + fee: BigInt(0), + netAmount: BigInt(1_000_000), + }); + }); + + it('throws for a zero or negative amount', () => { + expect(() => calculateWithdrawalFee(BigInt(0), 200)).toThrow('Withdrawal amount must be positive'); + expect(() => calculateWithdrawalFee(BigInt(-1), 200)).toThrow('Withdrawal amount must be positive'); + }); +}); diff --git a/backend/src/realtime/gateway.test.ts b/backend/src/realtime/gateway.test.ts new file mode 100644 index 00000000..62a313c9 --- /dev/null +++ b/backend/src/realtime/gateway.test.ts @@ -0,0 +1,87 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import jwt from 'jsonwebtoken'; +import { io as ioClient, type Socket as ClientSocket } from 'socket.io-client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { config } from '../config/index.js'; +import { initRealtime, emitBalanceUpdated } from './gateway.js'; + +function makeToken(payload: { sub: string; stellarAddress: string }): string { + return jwt.sign(payload, config.auth.jwtSecret, { expiresIn: '15m' }); +} + +describe('balance.updated (issue #951)', () => { + let httpServer: ReturnType; + let port: number; + let clientSocket: ClientSocket; + + beforeEach(async () => { + httpServer = createServer(); + initRealtime(httpServer); + await new Promise((resolve) => httpServer.listen(0, resolve)); + port = (httpServer.address() as AddressInfo).port; + }); + + afterEach(() => { + clientSocket?.close(); + httpServer.close(); + }); + + it('delivers balance.updated only to the balance owner, after they subscribe', async () => { + const userId = 'user-1'; + const token = makeToken({ sub: userId, stellarAddress: 'GOWNER' }); + + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connect', () => resolve())); + clientSocket.emit('subscribe:notifications', userId); + // Give the server a tick to process the join before we emit. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const payload = new Promise((resolve) => clientSocket.on('balance.updated', resolve)); + + emitBalanceUpdated({ + userId, + stellarAddress: 'GOWNER', + totalReceived: '5000000', + totalWithdrawn: '1000000', + withdrawableBalance: '4000000', + }); + + await expect(payload).resolves.toMatchObject({ + userId, + withdrawableBalance: '4000000', + }); + }); + + it('rejects subscribing to another user\'s balance room', async () => { + const token = makeToken({ sub: 'user-1', stellarAddress: 'GOWNER' }); + + clientSocket = ioClient(`http://localhost:${port}`, { + auth: { token }, + transports: ['websocket'], + }); + + await new Promise((resolve) => clientSocket.on('connect', () => resolve())); + + const errorEvent = new Promise((resolve) => clientSocket.on('error', resolve)); + clientSocket.emit('subscribe:notifications', 'someone-elses-id'); + + await expect(errorEvent).resolves.toMatchObject({ message: 'Forbidden' }); + }); + + it('rejects a connection with no auth token', async () => { + clientSocket = ioClient(`http://localhost:${port}`, { + transports: ['websocket'], + }); + + const err = await new Promise((resolve) => { + clientSocket.on('connect_error', resolve); + }); + + expect(err.message).toMatch(/token/i); + }); +}); diff --git a/backend/src/realtime/gateway.ts b/backend/src/realtime/gateway.ts index cdfbfcd4..380a201a 100644 --- a/backend/src/realtime/gateway.ts +++ b/backend/src/realtime/gateway.ts @@ -11,6 +11,7 @@ import type { InterServerEvents, SocketData, NotificationPayload, + BalanceUpdatedPayload, } from './types.js'; import type { TipResponseDto } from '../modules/tips/tips.dto.js'; @@ -118,6 +119,16 @@ export function emitNotificationCreated(notification: NotificationPayload): void ); } -export function getIO(): RealtimeServer | null { +/** Notifies a user's authenticated sockets (the `user:` room) that their balance changed. */ +export function emitBalanceUpdated(balance: BalanceUpdatedPayload): void { + if (!io) return; + io.to(`user:${balance.userId}`).emit('balance.updated', balance); + logger.debug( + { userId: balance.userId, room: `user:${balance.userId}` }, + 'Emitted balance.updated', + ); +} + +export function getIO(): SocketIOServer | null { return io; } diff --git a/backend/src/realtime/index.ts b/backend/src/realtime/index.ts index ace4327f..dc754da1 100644 --- a/backend/src/realtime/index.ts +++ b/backend/src/realtime/index.ts @@ -1,8 +1,7 @@ -export { initRealtime, emitTipCreated, emitNotificationCreated, getIO } from './gateway.js'; +export { initRealtime, emitTipCreated, emitNotificationCreated, emitBalanceUpdated, getIO } from "./gateway.js"; export type { ServerToClientEvents, ClientToServerEvents, - InterServerEvents, - SocketData, NotificationPayload, -} from './types.js'; + BalanceUpdatedPayload, +} from "./types.js"; diff --git a/backend/src/realtime/types.ts b/backend/src/realtime/types.ts index 0f364759..3d9547eb 100644 --- a/backend/src/realtime/types.ts +++ b/backend/src/realtime/types.ts @@ -14,6 +14,7 @@ export interface ServerToClientEvents { error: (payload: { code: string; message: string }) => void; 'tip.created': (tip: TipResponseDto) => void; 'notification.created': (notification: NotificationPayload) => void; + 'balance.updated': (balance: BalanceUpdatedPayload) => void; } /** Events a client may emit to the server. */ @@ -39,3 +40,11 @@ export interface NotificationPayload { payload: unknown; createdAt: string; } + +export interface BalanceUpdatedPayload { + userId: string; + stellarAddress: string; + totalReceived: string; + totalWithdrawn: string; + withdrawableBalance: string; +}