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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
24 changes: 12 additions & 12 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
2 changes: 2 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
5 changes: 5 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion backend/src/modules/tips/tips.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 55 additions & 5 deletions backend/src/modules/tips/tips.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const {
mockCreate,
mockUpdate,
mockGroupBy,
mockUserFindUnique,
mockCreateNotification,
mockFindUniqueUser,
mockEmitBalanceUpdated,
mockGetWithdrawableBalance,
} = vi.hoisted(() => ({
mockGetAccount: vi.fn(),
mockSimulateTransaction: vi.fn(),
Expand All @@ -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<typeof import('../../realtime/index.js')>();
return { ...actual, emitBalanceUpdated: mockEmitBalanceUpdated };
});

vi.mock('../withdrawals/withdrawals.service.js', () => ({
getWithdrawableBalance: mockGetWithdrawableBalance,
}));

vi.mock('@stellar/stellar-sdk', () => {
Expand Down Expand Up @@ -101,7 +112,7 @@ vi.mock('../../db/prisma.js', () => ({
groupBy: mockGroupBy,
},
user: {
findUnique: mockUserFindUnique,
findUnique: mockFindUniqueUser,
},
$disconnect: vi.fn(),
},
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down
31 changes: 30 additions & 1 deletion backend/src/modules/withdrawals/withdrawals.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,35 @@ export async function getWithdrawableBalance(userId: string): Promise<Withdrawab
};
}

const BPS_DIVISOR = BigInt(10_000);

export interface WithdrawalFee {
fee: bigint;
netAmount: bigint;
}

/**
* Pure function: split a gross withdrawal amount into the platform fee and
* the net amount the user receives. Fee is floored so the platform never
* rounds in its own favour beyond the configured rate.
*/
export function calculateWithdrawalFee(
amount: bigint,
feeBps: number = config.withdrawals.feeBps,
): WithdrawalFee {
if (amount <= BigInt(0)) {
throw new BadRequestError('Withdrawal amount must be positive');
}
const fee = (amount * BigInt(feeBps)) / BPS_DIVISOR;
return { fee, netAmount: amount - fee };
}

export interface PreparedWithdrawal {
unsignedTxXdr: string;
destination: string;
amount: string;
fee: string;
netAmount: string;
contractId: string;
networkPassphrase: string;
}
Expand All @@ -85,6 +110,8 @@ export async function prepareWithdrawal(
throw new BadRequestError('Insufficient balance');
}

const { fee, netAmount } = calculateWithdrawalFee(parsedAmount);

const server = new SorobanRpc.Server(config.stellar.rpcUrl, {
allowHttp: config.stellar.rpcUrl.startsWith('http://'),
});
Expand All @@ -104,7 +131,7 @@ export async function prepareWithdrawal(
contract.call(
'withdraw',
nativeToScVal(user.stellarAddress, { type: 'address' }),
nativeToScVal(amount, { type: 'i128' }),
nativeToScVal(netAmount.toString(), { type: 'i128' }),
),
)
.setTimeout(30)
Expand All @@ -125,6 +152,8 @@ export async function prepareWithdrawal(
unsignedTxXdr: prepared.build().toEnvelope().toXDR('base64'),
destination: user.stellarAddress,
amount,
fee: fee.toString(),
netAmount: netAmount.toString(),
contractId,
networkPassphrase,
};
Expand Down
31 changes: 31 additions & 0 deletions backend/src/modules/withdrawals/withdrawals.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import request from 'supertest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../app.js';
import { calculateWithdrawalFee } from './withdrawals.service.js';

const { mockFindMany, mockFindUnique, mockAggregate, mockGetAccount, mockSimulateTransaction } =
vi.hoisted(() => ({
Expand Down Expand Up @@ -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');
});
});
87 changes: 87 additions & 0 deletions backend/src/realtime/gateway.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createServer>;
let port: number;
let clientSocket: ClientSocket;

beforeEach(async () => {
httpServer = createServer();
initRealtime(httpServer);
await new Promise<void>((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<void>((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<void>((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<Error>((resolve) => {
clientSocket.on('connect_error', resolve);
});

expect(err.message).toMatch(/token/i);
});
});
Loading
Loading