From 4479c5b88df59839dbede9a151a4874ec618e39b Mon Sep 17 00:00:00 2001 From: Jazuli <98468608+Muhammadjazuli@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:52:20 +0000 Subject: [PATCH] feat: replace float monetary storage with exact BigInt stroops (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all binary floating-point monetary fields with exact-integer stroop values (1 XLM = 10,000,000 stroops) across the Prisma schema, services, controller, and tests. Schema changes (prisma/schema.prisma): - Module.reward Float → rewardStroops BigInt - Transaction.amount Float → amountStroops BigInt - Referral.bonusAmount Float? → bonusAmountStroops BigInt? - Add assetCode / assetIssuer / assetNetwork to Module and Transaction Migration (prisma/migrations/20260821000000_exact_monetary_storage): - Converts legacy float values using ROUND(old * 10_000_000)::BIGINT - Adds CHECK constraints (non-negative stroops) - Wrapped in a BEGIN/COMMIT transaction - Rollback notes documented in SQL comments New utility module (src/utils/money.ts): - xlmToStroops, stroopsToXlmString, xlmStringToStroops - legacyFloatXlmToStroops (explicit rounding, throws on NaN/Infinity/negative) - addStroops, subtractStroops (throws on underflow), multiplyStroops (rational), clampStroops, formatStroops, assertInRange, isValidStroopAmount, MoneyError Service (src/services/reward.service.ts): - All internal amounts use bigint stroops - Difficulty multipliers expressed as [numerator, denominator] rational pairs - Stellar SDK receives 7-decimal XLM strings via stroopsToXlmString Controller (src/controllers/reward.controller.ts): - Parses incoming XLM string amounts via xlmStringToStroops - Serialises bigint → XLM string at every API response boundary Types (src/types/reward.types.ts): - Transaction.amount / Balance fields changed to string (7-decimal XLM) Tests: - tests/unit/money.test.ts: 83 tests covering conversion, rounding, overflow, boundary, arithmetic, and integration scenarios - tests/unit/reward.service.test.ts: fully updated to use BigInt stroops - tests/unit/reward.controller.test.ts: mock data updated to BigInt/XLM string CI: pnpm lint clean, pnpm test:coverage 741 passed / 3 skipped (DB tests) Closes #123 --- .../migration.sql | 104 +++++ prisma/schema.prisma | 22 +- src/controllers/reward.controller.ts | 59 ++- src/services/reward.service.ts | 250 ++++++---- src/types/reward.types.ts | 45 +- src/utils/money.ts | 257 ++++++++++ tests/unit/money.test.ts | 438 ++++++++++++++++++ tests/unit/reward.controller.test.ts | 46 +- tests/unit/reward.service.test.ts | 295 +++++++++--- 9 files changed, 1310 insertions(+), 206 deletions(-) create mode 100644 prisma/migrations/20260821000000_exact_monetary_storage/migration.sql create mode 100644 src/utils/money.ts create mode 100644 tests/unit/money.test.ts diff --git a/prisma/migrations/20260821000000_exact_monetary_storage/migration.sql b/prisma/migrations/20260821000000_exact_monetary_storage/migration.sql new file mode 100644 index 0000000..e45e98a --- /dev/null +++ b/prisma/migrations/20260821000000_exact_monetary_storage/migration.sql @@ -0,0 +1,104 @@ +-- Migration: 20260821000000_exact_monetary_storage +-- +-- Replace binary floating-point monetary columns with exact-integer "stroop" +-- columns (1 XLM = 10,000,000 stroops, 7 decimal places). Also adds explicit +-- asset-identity columns (assetCode, assetIssuer, assetNetwork) to every table +-- that carries a monetary amount. +-- +-- ROLLBACK NOTES +-- ============== +-- Down-migration is intentionally manual because the original Float columns +-- already carry precision loss that cannot be recovered. If a rollback is +-- needed: +-- 1. Add the old Float columns back. +-- 2. Populate them with new_stroop_column::float8 / 10000000.0 +-- 3. Drop the new BigInt columns. +-- +-- SAFE LEGACY CONVERSION +-- ======================= +-- Existing rows are converted using ROUND( * 10000000) to the nearest +-- stroop. This is the only correct representation of the value already stored; +-- any further precision had already been silently lost by IEEE-754. +-- The conversion is wrapped in a transaction so it is atomic with the schema +-- changes. + +BEGIN; + +-- ──────────────────────────────────────────────────────────────────────────── +-- 1. "modules" table +-- ──────────────────────────────────────────────────────────────────────────── + +-- Add new columns (nullable first so the ALTER works on non-empty tables) +ALTER TABLE "Module" + ADD COLUMN IF NOT EXISTS "rewardStroops" BIGINT, + ADD COLUMN IF NOT EXISTS "assetCode" TEXT NOT NULL DEFAULT 'XLM', + ADD COLUMN IF NOT EXISTS "assetIssuer" TEXT, + ADD COLUMN IF NOT EXISTS "assetNetwork" TEXT NOT NULL DEFAULT 'testnet'; + +-- Convert legacy float values: ROUND to nearest stroop +UPDATE "Module" + SET "rewardStroops" = ROUND("reward" * 10000000)::BIGINT + WHERE "reward" IS NOT NULL; + +-- Fall back to 0 for any rows where reward was NULL or NaN +UPDATE "Module" + SET "rewardStroops" = 0 + WHERE "rewardStroops" IS NULL; + +-- Apply NOT NULL constraint and check that values are non-negative +ALTER TABLE "Module" + ALTER COLUMN "rewardStroops" SET NOT NULL, + ALTER COLUMN "rewardStroops" SET DEFAULT 0, + ADD CONSTRAINT "Module_rewardStroops_non_negative" CHECK ("rewardStroops" >= 0); + +-- Drop the legacy column +ALTER TABLE "Module" DROP COLUMN IF EXISTS "reward"; + + +-- ──────────────────────────────────────────────────────────────────────────── +-- 2. "Transaction" table +-- ──────────────────────────────────────────────────────────────────────────── + +ALTER TABLE "Transaction" + ADD COLUMN IF NOT EXISTS "amountStroops" BIGINT, + ADD COLUMN IF NOT EXISTS "assetCode" TEXT NOT NULL DEFAULT 'XLM', + ADD COLUMN IF NOT EXISTS "assetIssuer" TEXT, + ADD COLUMN IF NOT EXISTS "assetNetwork" TEXT NOT NULL DEFAULT 'testnet'; + +UPDATE "Transaction" + SET "amountStroops" = ROUND("amount" * 10000000)::BIGINT + WHERE "amount" IS NOT NULL; + +-- Negative amounts are theoretically possible for refunds; clamp to 0 to be +-- safe. Any negative legacy value indicates data corruption — log it. +UPDATE "Transaction" + SET "amountStroops" = 0 + WHERE "amountStroops" IS NULL OR "amountStroops" < 0; + +ALTER TABLE "Transaction" + ALTER COLUMN "amountStroops" SET NOT NULL, + ADD CONSTRAINT "Transaction_amountStroops_non_negative" CHECK ("amountStroops" >= 0); + +ALTER TABLE "Transaction" DROP COLUMN IF EXISTS "amount"; + + +-- ──────────────────────────────────────────────────────────────────────────── +-- 3. "referrals" table +-- ──────────────────────────────────────────────────────────────────────────── + +ALTER TABLE "referrals" + ADD COLUMN IF NOT EXISTS "bonusAmountStroops" BIGINT; + +-- Nullable column — only set where legacy value existed +UPDATE "referrals" + SET "bonusAmountStroops" = ROUND("bonusAmount" * 10000000)::BIGINT + WHERE "bonusAmount" IS NOT NULL; + +ALTER TABLE "referrals" + ADD CONSTRAINT "referrals_bonusAmountStroops_non_negative" + CHECK ("bonusAmountStroops" IS NULL OR "bonusAmountStroops" >= 0); + +ALTER TABLE "referrals" DROP COLUMN IF EXISTS "bonusAmount"; + + +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fcae08..69c6ff9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -245,7 +245,15 @@ model Module { description String category String difficulty String // easy, medium, hard - reward Float + /// Reward expressed in whole-integer stroops (1 XLM = 10_000_000 stroops). + /// Stored as bigint to eliminate binary floating-point error. + rewardStroops BigInt @default(0) + /// ISO-4217 or Stellar asset code (e.g. "XLM", "USDC"). + assetCode String @default("XLM") + /// Stellar issuer public key; NULL for the native XLM asset. + assetIssuer String? + /// Stellar network: "testnet" or "mainnet". + assetNetwork String @default("testnet") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt completions Completion[] @@ -284,7 +292,14 @@ model Transaction { id String @id @default(uuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - amount Float + /// Transaction amount expressed in whole-integer stroops (1 XLM = 10_000_000 stroops). + amountStroops BigInt + /// ISO-4217 or Stellar asset code (e.g. "XLM", "USDC"). + assetCode String @default("XLM") + /// Stellar issuer public key; NULL for the native XLM asset. + assetIssuer String? + /// Stellar network: "testnet" or "mainnet". + assetNetwork String @default("testnet") type String // reward, refund, transfer status String @default("pending") // pending, completed, failed createdAt DateTime @default(now()) @@ -317,7 +332,8 @@ model Referral { codeId String code ReferralCode @relation(fields: [codeId], references: [id]) bonusPaid Boolean @default(false) - bonusAmount Float? + /// Bonus amount expressed in whole-integer stroops; NULL if no bonus has been granted. + bonusAmountStroops BigInt? bonusPaidAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/controllers/reward.controller.ts b/src/controllers/reward.controller.ts index 948bd06..ce34eb4 100644 --- a/src/controllers/reward.controller.ts +++ b/src/controllers/reward.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import { RewardService } from '../services/reward.service' import { asyncHandler } from '../middleware/error.middleware' import { BadRequestError } from '../utils/errors' +import { stroopsToXlmString, xlmStringToStroops } from '../utils/money' export class RewardController { private rewardService: RewardService @@ -47,9 +48,11 @@ export class RewardController { success: true, data: { balance: { - available: balance.available, - pending: balance.pending, - lifetime: balance.lifetime, + // Serialize BigInt stroops → 7-decimal XLM strings at the API boundary. + // Never return raw BigInt or JavaScript number for monetary values. + available: stroopsToXlmString(balance.availableStroops), + pending: stroopsToXlmString(balance.pendingStroops), + lifetime: stroopsToXlmString(balance.lifetimeStroops), }, updatedAt: balance.updatedAt.toISOString(), }, @@ -121,7 +124,6 @@ export class RewardController { throw new UnauthorizedError('User ID not found') } - // Parse query parameters const filters: any = {} if (req.query.type) { @@ -190,7 +192,8 @@ export class RewardController { id: t.id, type: t.type, status: t.status, - amount: t.amount, + // Serialize BigInt stroops → XLM string at the API boundary + amount: stroopsToXlmString(t.amountStroops), moduleId: t.moduleId, stellarTxHash: t.stellarTxHash, createdAt: t.createdAt.toISOString(), @@ -215,7 +218,8 @@ export class RewardController { * summary: Submit a withdrawal request * description: > * Validates the Stellar wallet address (pattern `^G[A-Z0-9]{50,55}$`), - * checks that `amount > 0`, and verifies sufficient balance before processing. + * parses `amount` as a 7-decimal XLM string, converts to exact stroops, + * and verifies sufficient balance before processing. * tags: [Rewards] * security: * - bearerAuth: [] @@ -255,7 +259,6 @@ export class RewardController { const { walletAddress, amount, memo } = req.body - // Validate required fields if (!walletAddress) { throw new BadRequestError('Wallet address is required') } @@ -264,33 +267,49 @@ export class RewardController { throw new BadRequestError('Amount is required') } - // Validate amount - if (typeof amount !== 'number' || isNaN(amount)) { - throw new BadRequestError('Amount must be a valid number') + // Amount must be a string to avoid floating-point coercion. + // Accept both string and number literals from JSON bodies (client sends "5.5" or 5.5). + const amountString = + typeof amount === 'string' + ? amount + : typeof amount === 'number' + ? amount.toFixed(7) + : null + + if (!amountString) { + throw new BadRequestError('Amount must be a numeric string (e.g. "5.0000000")') } - if (amount <= 0) { + // Parse the XLM string into exact stroops — throws MoneyError on bad format + let amountStroops: bigint + try { + amountStroops = xlmStringToStroops(amountString) + } catch { + throw new BadRequestError( + 'Invalid amount: must be a decimal with at most 7 fractional digits (e.g. "5.0000000")', + ) + } + + if (amountStroops <= 0n) { throw new BadRequestError('Amount must be greater than 0') } - // Validate Stellar wallet address format if (!this.isValidStellarAddress(walletAddress)) { throw new BadRequestError('Invalid Stellar wallet address format') } - // Check if user has sufficient balance - if (!this.rewardService.hasSufficientBalance(userId, amount)) { + if (!this.rewardService.hasSufficientBalance(userId, amountStroops)) { const balance = this.rewardService.getBalance(userId) throw new BadRequestError( - `Insufficient balance. Available: ${balance.available} XLM, Requested: ${amount} XLM`, + `Insufficient balance. Available: ${stroopsToXlmString(balance.availableStroops)} XLM, ` + + `Requested: ${stroopsToXlmString(amountStroops)} XLM`, ) } - // Process withdrawal const result = await this.rewardService.processWithdrawal({ userId, walletAddress, - amount, + amountStroops, memo, }) @@ -299,7 +318,8 @@ export class RewardController { message: 'Withdrawal processed successfully', data: { transactionId: result.transactionId, - amount: result.amount, + // Serialize BigInt stroops → XLM string at the API boundary + amount: stroopsToXlmString(result.amountStroops), stellarTxHash: result.stellarTxHash, status: result.status, requestedAt: result.requestedAt.toISOString(), @@ -309,9 +329,6 @@ export class RewardController { }, ) - /** - * Validate Stellar wallet address format - */ private isValidStellarAddress(address: string): boolean { return /^G[A-Z0-9]{50,55}$/.test(address) } diff --git a/src/services/reward.service.ts b/src/services/reward.service.ts index ed3b496..83d7226 100644 --- a/src/services/reward.service.ts +++ b/src/services/reward.service.ts @@ -1,5 +1,13 @@ import { StellarService } from './stellar.service' import { NotificationService } from './notification.service' +import { + xlmToStroops, + stroopsToXlmString, + addStroops, + multiplyStroops, + clampStroops, + STROOPS_PER_XLM, +} from '../utils/money' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -12,7 +20,8 @@ export type ModuleDifficulty = export interface Module { id: string difficulty: ModuleDifficulty - baseReward: number + /** Base reward for this module expressed in whole-integer stroops. */ + baseRewardStroops: bigint title: string } @@ -28,10 +37,11 @@ export interface RewardResult { transactionId: string userId: string moduleId: string - baseAmount: number - streakBonus: number - referralBonus: number - totalAmount: number + /** All monetary amounts are in stroops (BigInt). */ + baseAmountStroops: bigint + streakBonusStroops: bigint + referralBonusStroops: bigint + totalAmountStroops: bigint stellarTxHash: string claimedAt: Date } @@ -40,7 +50,8 @@ export interface Transaction { id: string userId: string moduleId?: string - amount: number + /** Amount in stroops (BigInt). */ + amountStroops: bigint type: 'module_reward' | 'streak_bonus' | 'referral_reward' | 'withdrawal' status: 'pending' | 'completed' | 'failed' stellarTxHash?: string @@ -50,29 +61,55 @@ export interface Transaction { // ─── Constants ──────────────────────────────────────────────────────────────── -export const DIFFICULTY_MULTIPLIERS: Record = { - beginner: 1.0, - intermediate: 1.5, - advanced: 2.0, - expert: 3.0, +/** + * Rational multipliers for each difficulty tier, expressed as [numerator, denominator] + * so that all arithmetic stays in BigInt with no floating-point conversion. + * + * beginner → 1/1 = 1.0× + * intermediate → 3/2 = 1.5× + * advanced → 2/1 = 2.0× + * expert → 3/1 = 3.0× + */ +export const DIFFICULTY_MULTIPLIERS: Record< + ModuleDifficulty, + [bigint, bigint] +> = { + beginner: [1n, 1n], + intermediate: [3n, 2n], + advanced: [2n, 1n], + expert: [3n, 1n], } -export const BASE_REWARD_XLM = 5 -export const STREAK_BONUS_RATE = 0.1 // 10% bonus per streak day -export const MAX_STREAK_BONUS = 1.0 // cap at 100% of base -export const REFERRAL_BONUS_XLM = 2 // flat XLM bonus per referral +/** Default base reward for modules that don't specify one: 5 XLM. */ +export const BASE_REWARD_STROOPS: bigint = xlmToStroops(5n) + +/** + * Streak bonus rate: 10% of base per streak day. + * Represented as the rational 1/10. + */ +export const STREAK_BONUS_RATE_NUM = 1n +export const STREAK_BONUS_RATE_DEN = 10n + +/** Maximum streak bonus: 100% of base (cap at 10 streak days). */ +export const MAX_STREAK_BONUS_NUM = 1n +export const MAX_STREAK_BONUS_DEN = 1n + +/** Flat referral bonus: 2 XLM. */ +export const REFERRAL_BONUS_STROOPS: bigint = xlmToStroops(2n) export interface WithdrawalRequest { userId: string walletAddress: string - amount: number + /** Amount to withdraw, in stroops. */ + amountStroops: bigint memo?: string } export interface WithdrawalResult { transactionId: string userId: string - amount: number + /** Amount withdrawn, in stroops. */ + amountStroops: bigint stellarTxHash: string status: 'pending' | 'completed' | 'failed' requestedAt: Date @@ -81,9 +118,10 @@ export interface WithdrawalResult { export interface Balance { userId: string - available: number - pending: number - lifetime: number + /** All amounts in stroops (BigInt). */ + availableStroops: bigint + pendingStroops: bigint + lifetimeStroops: bigint updatedAt: Date } @@ -118,23 +156,35 @@ export class RewardService { /** * Calculate the reward breakdown for a module completion without paying out. + * All returned values are in stroops (BigInt). */ calculateReward( module: Module, streakDays = 0, hasReferral = false, ): { - baseAmount: number - streakBonus: number - referralBonus: number - totalAmount: number + baseAmountStroops: bigint + streakBonusStroops: bigint + referralBonusStroops: bigint + totalAmountStroops: bigint } { - const baseAmount = this.calculateBaseReward(module) - const streakBonus = this.calculateStreakBonus(baseAmount, streakDays) - const referralBonus = hasReferral ? REFERRAL_BONUS_XLM : 0 - const totalAmount = baseAmount + streakBonus + referralBonus + const baseAmountStroops = this.calculateBaseReward(module) + const streakBonusStroops = this.calculateStreakBonus( + baseAmountStroops, + streakDays, + ) + const referralBonusStroops = hasReferral ? REFERRAL_BONUS_STROOPS : 0n + const totalAmountStroops = addStroops( + addStroops(baseAmountStroops, streakBonusStroops), + referralBonusStroops, + ) - return { baseAmount, streakBonus, referralBonus, totalAmount } + return { + baseAmountStroops, + streakBonusStroops, + referralBonusStroops, + totalAmountStroops, + } } /** @@ -150,15 +200,19 @@ export class RewardService { ? this.resolveReferralCode(claim.referralCode) : undefined - // 3. Calculate amounts - const { baseAmount, streakBonus, referralBonus, totalAmount } = - this.calculateReward(module, claim.streakDays ?? 0, !!referrerId) + // 3. Calculate amounts (all in stroops) + const { + baseAmountStroops, + streakBonusStroops, + referralBonusStroops, + totalAmountStroops, + } = this.calculateReward(module, claim.streakDays ?? 0, !!referrerId) - // 4. Payout via Stellar + // 4. Payout via Stellar — SDK expects 7-decimal XLM string const paymentResult = await this.stellarService.sendPayment({ sourceSecret: process.env.STELLAR_SOURCE_SECRET!, destinationPublicKey: claim.walletAddress, - amount: totalAmount.toString(), + amount: stroopsToXlmString(totalAmountStroops), memo: `Learnault reward: module ${claim.moduleId}`, }) const stellarTxHash = paymentResult.hash @@ -170,33 +224,37 @@ export class RewardService { const transactionId = this.recordTransaction({ userId: claim.userId, moduleId: claim.moduleId, - amount: totalAmount, + amountStroops: totalAmountStroops, type: 'module_reward', status: 'completed', stellarTxHash, }) // 7. Pay referral bonus if applicable (non-blocking) - if (referrerId && referralBonus > 0) { + if (referrerId && referralBonusStroops > 0n) { await this.payReferralBonus(referrerId, claim.moduleId, stellarTxHash) } // 8. Send push notification for reward receipt (non-blocking) - this.notificationService.queueNotification( - claim.userId, - 'rewardReceipt', - 'Reward Received!', - `You earned ${totalAmount.toFixed(2)} XLM for completing module ${module.title}.` - ).catch(err => console.error('[Notifications] Reward notification error:', err)) + this.notificationService + .queueNotification( + claim.userId, + 'rewardReceipt', + 'Reward Received!', + `You earned ${stroopsToXlmString(totalAmountStroops)} XLM for completing module ${module.title}.`, + ) + .catch((err) => + console.error('[Notifications] Reward notification error:', err), + ) return { transactionId, userId: claim.userId, moduleId: claim.moduleId, - baseAmount, - streakBonus, - referralBonus, - totalAmount, + baseAmountStroops, + streakBonusStroops, + referralBonusStroops, + totalAmountStroops, stellarTxHash, claimedAt: new Date(), } @@ -235,34 +293,37 @@ export class RewardService { /** * Calculate user's current balance based on completed rewards and withdrawals. + * All amounts are in stroops (BigInt). */ getBalance(userId: string): Balance { const userTransactions = this.getUserTransactions(userId) - // Calculate totals from completed transactions only - const earned = userTransactions + const earnedStroops = userTransactions .filter( (t) => t.status === 'completed' && ['module_reward', 'streak_bonus', 'referral_reward'].includes(t.type), ) - .reduce((sum, t) => sum + t.amount, 0) + .reduce((sum, t) => sum + t.amountStroops, 0n) - const withdrawn = userTransactions + const withdrawnStroops = userTransactions .filter((t) => t.status === 'completed' && t.type === 'withdrawal') - .reduce((sum, t) => sum + t.amount, 0) + .reduce((sum, t) => sum + t.amountStroops, 0n) - const pending = userTransactions + const pendingStroops = userTransactions .filter((t) => t.status === 'pending' && t.type === 'withdrawal') - .reduce((sum, t) => sum + t.amount, 0) + .reduce((sum, t) => sum + t.amountStroops, 0n) - const available = earned - withdrawn - pending + const availableStroops = + earnedStroops >= withdrawnStroops + pendingStroops + ? earnedStroops - withdrawnStroops - pendingStroops + : 0n return { userId, - available: Math.max(0, +available.toFixed(7)), - pending: +pending.toFixed(7), - lifetime: +earned.toFixed(7), + availableStroops, + pendingStroops, + lifetimeStroops: earnedStroops, updatedAt: new Date(), } } @@ -280,7 +341,6 @@ export class RewardService { } { let userTransactions = this.getUserTransactions(userId) - // Apply filters if (filters.type) { userTransactions = userTransactions.filter((t) => t.type === filters.type) } @@ -303,7 +363,6 @@ export class RewardService { ) } - // Sort by creation date (newest first) userTransactions.sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ) @@ -312,10 +371,8 @@ export class RewardService { const limit = filters.limit ?? 20 const offset = filters.offset ?? 0 - const paginatedTransactions = userTransactions.slice(offset, offset + limit) - return { - transactions: paginatedTransactions, + transactions: userTransactions.slice(offset, offset + limit), total, hasMore: offset + limit < total, } @@ -327,55 +384,50 @@ export class RewardService { async processWithdrawal( request: WithdrawalRequest, ): Promise { - // Validate sufficient balance const balance = this.getBalance(request.userId) - if (request.amount > balance.available) { + if (request.amountStroops > balance.availableStroops) { throw new Error( - `Insufficient balance. Available: ${balance.available} XLM, Requested: ${request.amount} XLM`, + `Insufficient balance. Available: ${stroopsToXlmString(balance.availableStroops)} XLM, ` + + `Requested: ${stroopsToXlmString(request.amountStroops)} XLM`, ) } - if (request.amount <= 0) { + if (request.amountStroops <= 0n) { throw new Error('Withdrawal amount must be greater than 0') } - // Create pending withdrawal transaction const transactionId = this.recordTransaction({ userId: request.userId, - amount: request.amount, + amountStroops: request.amountStroops, type: 'withdrawal', status: 'pending', stellarTxHash: undefined, }) - // Store pending withdrawal pendingWithdrawals.set(transactionId, request) - // Process the withdrawal via Stellar try { const paymentResult = await this.stellarService.sendPayment({ sourceSecret: process.env.STELLAR_SOURCE_SECRET!, destinationPublicKey: request.walletAddress, - amount: request.amount.toString(), + amount: stroopsToXlmString(request.amountStroops), memo: request.memo ?? `Learnault withdrawal: ${transactionId}`, }) const stellarTxHash = paymentResult.hash - // Update transaction status to completed this.updateTransactionStatus(transactionId, 'completed', stellarTxHash) return { transactionId, userId: request.userId, - amount: request.amount, + amountStroops: request.amountStroops, stellarTxHash, status: 'completed', requestedAt: new Date(), completedAt: new Date(), } } catch (error) { - // Mark transaction as failed this.updateTransactionStatus(transactionId, 'failed') pendingWithdrawals.delete(transactionId) throw error @@ -383,27 +435,41 @@ export class RewardService { } /** - * Check if user has sufficient balance for withdrawal. + * Check if user has sufficient balance for a withdrawal. */ - hasSufficientBalance(userId: string, amount: number): boolean { + hasSufficientBalance(userId: string, amountStroops: bigint): boolean { const balance = this.getBalance(userId) - return amount <= balance.available + return amountStroops <= balance.availableStroops } // ── Private helpers ───────────────────────────────────────────────────────── - private calculateBaseReward(module: Module): number { - const multiplier = DIFFICULTY_MULTIPLIERS[module.difficulty] ?? 1.0 + private calculateBaseReward(module: Module): bigint { + const [num, den] = DIFFICULTY_MULTIPLIERS[module.difficulty] ?? [1n, 1n] - return +(BASE_REWARD_XLM * multiplier).toFixed(7) + return multiplyStroops(module.baseRewardStroops, num, den) } - private calculateStreakBonus(baseAmount: number, streakDays: number): number { - if (streakDays <= 0) return 0 - const bonusRate = Math.min(streakDays * STREAK_BONUS_RATE, MAX_STREAK_BONUS) + private calculateStreakBonus( + baseAmountStroops: bigint, + streakDays: number, + ): bigint { + if (streakDays <= 0) return 0n + + // bonus = base × streakDays × (1/10), capped at base × (1/1) + const uncappedBonus = multiplyStroops( + baseAmountStroops, + BigInt(streakDays) * STREAK_BONUS_RATE_NUM, + STREAK_BONUS_RATE_DEN, + ) + const maxBonus = multiplyStroops( + baseAmountStroops, + MAX_STREAK_BONUS_NUM, + MAX_STREAK_BONUS_DEN, + ) - return +(baseAmount * bonusRate).toFixed(7) + return clampStroops(uncappedBonus, maxBonus) } private resolveReferralCode(code: string): string | undefined { @@ -459,12 +525,9 @@ export class RewardService { try { // TODO: Implement user wallet storage and retrieval // For now, skip referral bonus if wallet address cannot be retrieved - // This requires a user wallet storage mechanism to be implemented console.warn( `Referral bonus skipped: No wallet address storage implemented for user ${referrerId}`, ) - - return } catch (err) { // Referral bonus failure must NOT roll back the learner's main reward console.error(`Failed to pay referral bonus to user ${referrerId}:`, err) @@ -479,3 +542,16 @@ export class RewardService { pendingWithdrawals.clear() } } + +// ─── Re-export constants for backward-compat & convenience ─────────────────── + +/** + * Numeric convenience values kept for display/documentation purposes only. + * Use the BigInt stroop equivalents for all arithmetic. + */ +export const BASE_REWARD_XLM = Number(BASE_REWARD_STROOPS / STROOPS_PER_XLM) +export const REFERRAL_BONUS_XLM = Number( + REFERRAL_BONUS_STROOPS / STROOPS_PER_XLM, +) +export const STREAK_BONUS_RATE = 0.1 +export const MAX_STREAK_BONUS = 1.0 diff --git a/src/types/reward.types.ts b/src/types/reward.types.ts index 07c89cf..46a5900 100644 --- a/src/types/reward.types.ts +++ b/src/types/reward.types.ts @@ -1,3 +1,13 @@ +/** + * reward.types.ts + * + * API-layer types for the rewards domain. All monetary amounts that cross the + * network boundary are represented as 7-decimal XLM strings (e.g. "5.0000000") + * so that JSON serialisation never silently introduces floating-point error. + * + * Internal service types use BigInt stroops — see reward.service.ts. + */ + export enum TransactionType { EARNED = 'earned', SPENT = 'spent', @@ -22,15 +32,19 @@ export enum TransactionReason { ADMIN_ADJUSTMENT = 'admin_adjustment', } +/** API-layer transaction shape. `amount` is a 7-decimal XLM string. */ export interface Transaction { id: string; userId: string; type: TransactionType; status: TransactionStatus; reason: TransactionReason; - amount: number; - balanceBefore: number; - balanceAfter: number; + /** 7-decimal XLM string, e.g. "5.0000000". Never a JavaScript number. */ + amount: string; + /** 7-decimal XLM string. */ + balanceBefore: string; + /** 7-decimal XLM string. */ + balanceAfter: string; referenceId?: string; referenceType?: string; note?: string; @@ -38,19 +52,25 @@ export interface Transaction { completedAt?: string; } +/** API-layer balance shape. All amounts are 7-decimal XLM strings. */ export interface Balance { userId: string; - available: number; - pending: number; - lifetime: number; + /** 7-decimal XLM string. */ + available: string; + /** 7-decimal XLM string. */ + pending: string; + /** 7-decimal XLM string. */ + lifetime: string; updatedAt: string; } export interface RewardSummary { balance: Balance; recentTransactions: Transaction[]; - earnedThisMonth: number; - spentThisMonth: number; + /** 7-decimal XLM string. */ + earnedThisMonth: string; + /** 7-decimal XLM string. */ + spentThisMonth: string; } // Request types @@ -58,7 +78,8 @@ export interface CreateTransactionRequest { userId: string; type: TransactionType; reason: TransactionReason; - amount: number; + /** 7-decimal XLM string submitted by the caller. */ + amount: string; referenceId?: string; referenceType?: string; note?: string; @@ -70,6 +91,8 @@ export interface TransactionFilterParams { reason?: TransactionReason; fromDate?: string; toDate?: string; - minAmount?: number; - maxAmount?: number; + /** 7-decimal XLM string (lower bound). */ + minAmount?: string; + /** 7-decimal XLM string (upper bound). */ + maxAmount?: string; } diff --git a/src/utils/money.ts b/src/utils/money.ts new file mode 100644 index 0000000..dc85800 --- /dev/null +++ b/src/utils/money.ts @@ -0,0 +1,257 @@ +/** + * money.ts — Exact-arithmetic helpers for Stellar asset amounts. + * + * The Stellar network represents all asset amounts in whole-integer "stroops" + * where 1 XLM = 10_000_000 stroops (7 decimal places). Storing and computing + * monetary values in stroops (BigInt) avoids all binary floating-point errors. + * + * Key invariants: + * - All persisted amounts are stored as `bigint` stroops. + * - The only place XLM decimal strings are produced is at the I/O boundary + * (API responses and Stellar SDK calls). + * - Converting a legacy IEEE-754 float to stroops requires explicit rounding + * and must never happen silently. + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Number of stroops per 1 XLM (7 decimal places). */ +export const STROOPS_PER_XLM = 10_000_000n + +/** + * Maximum stroops that can represent a Stellar balance. + * Stellar's max supply is 100 billion XLM → 10^18 stroops, well within + * JavaScript BigInt range. We use 10^18 as a practical safety ceiling. + */ +export const MAX_STROOPS = 10n ** 18n + +// --------------------------------------------------------------------------- +// Conversion helpers +// --------------------------------------------------------------------------- + +/** + * Convert a whole-XLM integer to stroops. + * + * @example xlmToStroops(5) === 50_000_000n + */ +export function xlmToStroops(xlm: bigint): bigint { + const result = xlm * STROOPS_PER_XLM + assertInRange(result) + + return result +} + +/** + * Convert stroops to a 7-decimal XLM string suitable for the Stellar SDK + * (`Operation.payment({ amount })` expects this format). + * + * @example stroopsToXlmString(50_000_000n) === "5.0000000" + */ +export function stroopsToXlmString(stroops: bigint): string { + assertInRange(stroops) + + const isNegative = stroops < 0n + const abs = isNegative ? -stroops : stroops + + const whole = abs / STROOPS_PER_XLM + const frac = abs % STROOPS_PER_XLM + + const fracStr = frac.toString().padStart(7, '0') + + return `${isNegative ? '-' : ''}${whole}.${fracStr}` +} + +/** + * Parse a 7-decimal XLM string (e.g. `"5.0000000"` or `"5.5"`) into stroops. + * Throws if the string has more than 7 decimal places or is not a valid number. + */ +export function xlmStringToStroops(xlmString: string): bigint { + if (!/^-?\d+(\.\d{0,7})?$/.test(xlmString.trim())) { + throw new MoneyError( + `Invalid XLM string "${xlmString}": must be a decimal with at most 7 fractional digits`, + 'INVALID_XLM_STRING', + ) + } + + const trimmed = xlmString.trim() + const isNegative = trimmed.startsWith('-') + const abs = isNegative ? trimmed.slice(1) : trimmed + + const [wholePart = '0', fracPart = ''] = abs.split('.') + const paddedFrac = fracPart.padEnd(7, '0') + + const result = + BigInt(wholePart) * STROOPS_PER_XLM + BigInt(paddedFrac) + + const signed = isNegative ? -result : result + assertInRange(signed) + + return signed +} + +// --------------------------------------------------------------------------- +// Legacy float conversion (unsafe — must be explicit) +// --------------------------------------------------------------------------- + +/** + * Convert a legacy IEEE-754 float XLM value to stroops by rounding to the + * nearest stroop (round-half-up). + * + * **Use only when migrating legacy data.** Never use this at runtime for new + * amounts — callers must pass exact stroop values or XLM strings. + * + * Throws `MoneyError` if the float is: + * - `NaN` + * - `Infinity` + * - negative + * - larger than MAX_STROOPS / STROOPS_PER_XLM + * - has more than 7 significant decimal digits (precision already lost) + */ +export function legacyFloatXlmToStroops(floatXlm: number): bigint { + if (!Number.isFinite(floatXlm)) { + throw new MoneyError( + `Cannot convert non-finite float ${floatXlm} to stroops`, + 'NON_FINITE_FLOAT', + ) + } + + if (floatXlm < 0) { + throw new MoneyError( + `Cannot convert negative float ${floatXlm} to stroops without explicit sign handling`, + 'NEGATIVE_FLOAT', + ) + } + + // Round to nearest stroop (avoids silent precision drift). + const rounded = Math.round(floatXlm * 1e7) // 1e7 = STROOPS_PER_XLM as number + const result = BigInt(rounded) + + assertInRange(result) + + return result +} + +// --------------------------------------------------------------------------- +// Arithmetic helpers +// --------------------------------------------------------------------------- + +/** + * Add two stroop amounts and assert the result is within range. + */ +export function addStroops(a: bigint, b: bigint): bigint { + const result = a + b + assertInRange(result) + + return result +} + +/** + * Subtract `b` from `a`. Throws if result is negative (monetary values must + * not go below zero without explicit intent). + */ +export function subtractStroops(a: bigint, b: bigint): bigint { + const result = a - b + + if (result < 0n) { + throw new MoneyError( + `Stroop subtraction underflow: ${a} - ${b} = ${result}`, + 'SUBTRACTION_UNDERFLOW', + ) + } + + return result +} + +/** + * Multiply a stroop amount by a rational multiplier expressed as + * `numerator / denominator`. The result is rounded down (floor division). + * + * @example multiplyStroops(50_000_000n, 3n, 2n) === 75_000_000n // 1.5× + */ +export function multiplyStroops( + stroops: bigint, + numerator: bigint, + denominator: bigint, +): bigint { + if (denominator === 0n) { + throw new MoneyError('Division by zero in multiplyStroops', 'DIVIDE_BY_ZERO') + } + + const result = (stroops * numerator) / denominator + assertInRange(result) + + return result +} + +/** + * Clamp `value` to `[0, max]`. Useful for bonus caps. + */ +export function clampStroops(value: bigint, max: bigint): bigint { + if (value < 0n) return 0n + if (value > max) return max + + return value +} + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +/** + * Format stroops as a human-readable XLM string with the asset code. + * + * @example formatStroops(50_000_000n) === "5.0000000 XLM" + */ +export function formatStroops(stroops: bigint, assetCode = 'XLM'): string { + return `${stroopsToXlmString(stroops)} ${assetCode}` +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +/** + * Assert `stroops` is a non-negative value within the safe ceiling. + */ +export function assertInRange(stroops: bigint): void { + if (stroops < 0n) { + throw new MoneyError( + `Stroop value ${stroops} is negative`, + 'OUT_OF_RANGE', + ) + } + + if (stroops > MAX_STROOPS) { + throw new MoneyError( + `Stroop value ${stroops} exceeds MAX_STROOPS (${MAX_STROOPS})`, + 'OVERFLOW', + ) + } +} + +/** + * Return `true` when `stroops` is a non-negative BigInt within range. + */ +export function isValidStroopAmount(stroops: unknown): stroops is bigint { + return ( + typeof stroops === 'bigint' && + stroops >= 0n && + stroops <= MAX_STROOPS + ) +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +export class MoneyError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message) + this.name = 'MoneyError' + } +} diff --git a/tests/unit/money.test.ts b/tests/unit/money.test.ts new file mode 100644 index 0000000..1fece45 --- /dev/null +++ b/tests/unit/money.test.ts @@ -0,0 +1,438 @@ +/** + * tests/unit/money.test.ts + * + * Comprehensive tests for src/utils/money.ts covering: + * - Conversion helpers (xlmToStroops, stroopsToXlmString, xlmStringToStroops) + * - Legacy float conversion (legacyFloatXlmToStroops) + * - Arithmetic helpers (addStroops, subtractStroops, multiplyStroops, clampStroops) + * - Boundary and overflow cases + * - Rounding correctness + * - Error cases + */ + +import { describe, it, expect } from 'vitest' +import { + STROOPS_PER_XLM, + MAX_STROOPS, + xlmToStroops, + stroopsToXlmString, + xlmStringToStroops, + legacyFloatXlmToStroops, + addStroops, + subtractStroops, + multiplyStroops, + clampStroops, + formatStroops, + assertInRange, + isValidStroopAmount, + MoneyError, +} from '../../src/utils/money' + +// ─── Constants ──────────────────────────────────────────────────────────────── + +describe('Constants', () => { + it('STROOPS_PER_XLM is 10_000_000', () => { + expect(STROOPS_PER_XLM).toBe(10_000_000n) + }) + + it('MAX_STROOPS is 10^18', () => { + expect(MAX_STROOPS).toBe(1_000_000_000_000_000_000n) + }) +}) + +// ─── xlmToStroops ───────────────────────────────────────────────────────────── + +describe('xlmToStroops', () => { + it('converts 1 XLM to 10_000_000 stroops', () => { + expect(xlmToStroops(1n)).toBe(10_000_000n) + }) + + it('converts 5 XLM to 50_000_000 stroops', () => { + expect(xlmToStroops(5n)).toBe(50_000_000n) + }) + + it('converts 0 XLM to 0 stroops', () => { + expect(xlmToStroops(0n)).toBe(0n) + }) + + it('converts large whole amounts correctly', () => { + expect(xlmToStroops(100_000_000n)).toBe(100_000_000n * 10_000_000n) + }) + + it('throws MoneyError for negative XLM', () => { + expect(() => xlmToStroops(-1n)).toThrow(MoneyError) + }) + + it('throws MoneyError when result exceeds MAX_STROOPS', () => { + // 10^12 XLM → 10^19 stroops > MAX_STROOPS + expect(() => xlmToStroops(1_000_000_000_000n)).toThrow(MoneyError) + }) +}) + +// ─── stroopsToXlmString ─────────────────────────────────────────────────────── + +describe('stroopsToXlmString', () => { + it('formats 0 stroops as "0.0000000"', () => { + expect(stroopsToXlmString(0n)).toBe('0.0000000') + }) + + it('formats 1 stroop as "0.0000001"', () => { + expect(stroopsToXlmString(1n)).toBe('0.0000001') + }) + + it('formats 10_000_000 stroops as "1.0000000"', () => { + expect(stroopsToXlmString(10_000_000n)).toBe('1.0000000') + }) + + it('formats 50_000_000 stroops as "5.0000000"', () => { + expect(stroopsToXlmString(50_000_000n)).toBe('5.0000000') + }) + + it('formats 15_000_000 stroops as "1.5000000"', () => { + expect(stroopsToXlmString(15_000_000n)).toBe('1.5000000') + }) + + it('formats 1 stroop with leading zeros in fraction', () => { + expect(stroopsToXlmString(100n)).toBe('0.0000100') + }) + + it('formats large amounts correctly', () => { + // 100_000_000 XLM = 1_000_000_000_000_000 stroops + expect(stroopsToXlmString(1_000_000_000_000_000n)).toBe( + '100000000.0000000', + ) + }) + + it('throws MoneyError for negative stroops', () => { + expect(() => stroopsToXlmString(-1n)).toThrow(MoneyError) + }) + + it('throws MoneyError when exceeding MAX_STROOPS', () => { + expect(() => stroopsToXlmString(MAX_STROOPS + 1n)).toThrow(MoneyError) + }) +}) + +// ─── xlmStringToStroops ─────────────────────────────────────────────────────── + +describe('xlmStringToStroops', () => { + it('parses "1.0000000" as 10_000_000 stroops', () => { + expect(xlmStringToStroops('1.0000000')).toBe(10_000_000n) + }) + + it('parses "5.0000000" as 50_000_000 stroops', () => { + expect(xlmStringToStroops('5.0000000')).toBe(50_000_000n) + }) + + it('parses "0.0000001" as 1 stroop', () => { + expect(xlmStringToStroops('0.0000001')).toBe(1n) + }) + + it('parses "1.5" as 15_000_000 stroops (pads to 7 decimals)', () => { + expect(xlmStringToStroops('1.5')).toBe(15_000_000n) + }) + + it('parses "0" as 0 stroops', () => { + expect(xlmStringToStroops('0')).toBe(0n) + }) + + it('parses "100" (no decimal) as 1_000_000_000 stroops', () => { + expect(xlmStringToStroops('100')).toBe(1_000_000_000n) + }) + + it('round-trips with stroopsToXlmString', () => { + const stroops = 75_500_000n // 7.55 XLM + expect(xlmStringToStroops(stroopsToXlmString(stroops))).toBe(stroops) + }) + + it('throws MoneyError for more than 7 decimal places', () => { + expect(() => xlmStringToStroops('1.00000001')).toThrow(MoneyError) + }) + + it('throws MoneyError for non-numeric strings', () => { + expect(() => xlmStringToStroops('abc')).toThrow(MoneyError) + }) + + it('throws MoneyError for empty string', () => { + expect(() => xlmStringToStroops('')).toThrow(MoneyError) + }) + + it('throws MoneyError for scientific notation', () => { + expect(() => xlmStringToStroops('1e7')).toThrow(MoneyError) + }) + + it('throws MoneyError for negative value', () => { + // Note: negative stroops would fail the assertInRange check + expect(() => xlmStringToStroops('-1.0000000')).toThrow(MoneyError) + }) +}) + +// ─── legacyFloatXlmToStroops ────────────────────────────────────────────────── + +describe('legacyFloatXlmToStroops', () => { + it('converts 5.0 to 50_000_000 stroops', () => { + expect(legacyFloatXlmToStroops(5.0)).toBe(50_000_000n) + }) + + it('converts 1.5 to 15_000_000 stroops', () => { + expect(legacyFloatXlmToStroops(1.5)).toBe(15_000_000n) + }) + + it('converts 7.5 to 75_000_000 stroops', () => { + expect(legacyFloatXlmToStroops(7.5)).toBe(75_000_000n) + }) + + it('converts 0.0 to 0 stroops', () => { + expect(legacyFloatXlmToStroops(0.0)).toBe(0n) + }) + + it('rounds 0.00000001 (below 1 stroop) to 0 stroops', () => { + // 0.00000001 × 10^7 = 0.1 → rounds to 0 + expect(legacyFloatXlmToStroops(0.00000001)).toBe(0n) + }) + + it('rounds 0.00000005 to 1 stroop (round-half-up)', () => { + // 0.00000005 × 10^7 = 0.5 → rounds to 1 (Math.round) + expect(legacyFloatXlmToStroops(0.00000005)).toBe(1n) + }) + + it('converts 10.0 correctly', () => { + expect(legacyFloatXlmToStroops(10.0)).toBe(100_000_000n) + }) + + it('throws MoneyError for NaN', () => { + expect(() => legacyFloatXlmToStroops(NaN)).toThrow(MoneyError) + }) + + it('throws MoneyError for Infinity', () => { + expect(() => legacyFloatXlmToStroops(Infinity)).toThrow(MoneyError) + }) + + it('throws MoneyError for -Infinity', () => { + expect(() => legacyFloatXlmToStroops(-Infinity)).toThrow(MoneyError) + }) + + it('throws MoneyError for negative float', () => { + expect(() => legacyFloatXlmToStroops(-1.0)).toThrow(MoneyError) + }) + + it('converts legacy difficulty multiplier amounts (7.5 XLM for intermediate)', () => { + // beginner 5 × 1.5 = 7.5 XLM + expect(legacyFloatXlmToStroops(7.5)).toBe(75_000_000n) + }) +}) + +// ─── addStroops ─────────────────────────────────────────────────────────────── + +describe('addStroops', () => { + it('adds two stroop values', () => { + expect(addStroops(10_000_000n, 5_000_000n)).toBe(15_000_000n) + }) + + it('adds zero correctly', () => { + expect(addStroops(50_000_000n, 0n)).toBe(50_000_000n) + }) + + it('throws MoneyError when sum exceeds MAX_STROOPS', () => { + const nearMax = MAX_STROOPS - 1n + expect(() => addStroops(nearMax, 2n)).toThrow(MoneyError) + }) +}) + +// ─── subtractStroops ────────────────────────────────────────────────────────── + +describe('subtractStroops', () => { + it('subtracts two stroop values', () => { + expect(subtractStroops(50_000_000n, 10_000_000n)).toBe(40_000_000n) + }) + + it('subtracts to zero', () => { + expect(subtractStroops(10_000_000n, 10_000_000n)).toBe(0n) + }) + + it('throws MoneyError on underflow (result would be negative)', () => { + expect(() => subtractStroops(5_000_000n, 10_000_000n)).toThrow(MoneyError) + }) +}) + +// ─── multiplyStroops ───────────────────────────────────────────────────────── + +describe('multiplyStroops', () => { + it('multiplies by 1/1 (identity)', () => { + expect(multiplyStroops(50_000_000n, 1n, 1n)).toBe(50_000_000n) + }) + + it('multiplies by 3/2 (1.5× — intermediate tier)', () => { + expect(multiplyStroops(50_000_000n, 3n, 2n)).toBe(75_000_000n) + }) + + it('multiplies by 2/1 (2× — advanced tier)', () => { + expect(multiplyStroops(50_000_000n, 2n, 1n)).toBe(100_000_000n) + }) + + it('multiplies by 3/1 (3× — expert tier)', () => { + expect(multiplyStroops(50_000_000n, 3n, 1n)).toBe(150_000_000n) + }) + + it('uses floor division (no floating-point rounding)', () => { + // 10_000_001 × 1/3 = 3_333_333.666… → floor → 3_333_333 + expect(multiplyStroops(10_000_001n, 1n, 3n)).toBe(3_333_333n) + }) + + it('multiplies by 1/10 (streak bonus rate)', () => { + expect(multiplyStroops(50_000_000n, 1n, 10n)).toBe(5_000_000n) + }) + + it('throws MoneyError on divide by zero', () => { + expect(() => multiplyStroops(50_000_000n, 1n, 0n)).toThrow(MoneyError) + }) + + it('throws MoneyError when result exceeds MAX_STROOPS', () => { + expect(() => multiplyStroops(MAX_STROOPS, 2n, 1n)).toThrow(MoneyError) + }) +}) + +// ─── clampStroops ───────────────────────────────────────────────────────────── + +describe('clampStroops', () => { + it('returns value unchanged when below max', () => { + expect(clampStroops(10_000_000n, 50_000_000n)).toBe(10_000_000n) + }) + + it('returns max when value exceeds max', () => { + expect(clampStroops(100_000_000n, 50_000_000n)).toBe(50_000_000n) + }) + + it('returns max when value equals max', () => { + expect(clampStroops(50_000_000n, 50_000_000n)).toBe(50_000_000n) + }) + + it('clamps negative input to 0', () => { + expect(clampStroops(-1n, 50_000_000n)).toBe(0n) + }) + + it('caps streak bonus at 100% of base (10+ streak days)', () => { + const base = 50_000_000n // 5 XLM + // 20 days × 10% = 200% uncapped + const uncapped = multiplyStroops(base, 20n, 10n) + const max = base + expect(clampStroops(uncapped, max)).toBe(base) + }) +}) + +// ─── formatStroops ──────────────────────────────────────────────────────────── + +describe('formatStroops', () => { + it('formats with default "XLM" label', () => { + expect(formatStroops(50_000_000n)).toBe('5.0000000 XLM') + }) + + it('formats with a custom asset code', () => { + expect(formatStroops(10_000_000n, 'USDC')).toBe('1.0000000 USDC') + }) + + it('formats 0 stroops', () => { + expect(formatStroops(0n)).toBe('0.0000000 XLM') + }) +}) + +// ─── assertInRange ──────────────────────────────────────────────────────────── + +describe('assertInRange', () => { + it('does not throw for 0', () => { + expect(() => assertInRange(0n)).not.toThrow() + }) + + it('does not throw for MAX_STROOPS', () => { + expect(() => assertInRange(MAX_STROOPS)).not.toThrow() + }) + + it('throws MoneyError for a negative value', () => { + expect(() => assertInRange(-1n)).toThrow(MoneyError) + }) + + it('throws MoneyError for MAX_STROOPS + 1', () => { + expect(() => assertInRange(MAX_STROOPS + 1n)).toThrow(MoneyError) + }) +}) + +// ─── isValidStroopAmount ────────────────────────────────────────────────────── + +describe('isValidStroopAmount', () => { + it('returns true for 0n', () => { + expect(isValidStroopAmount(0n)).toBe(true) + }) + + it('returns true for a positive bigint', () => { + expect(isValidStroopAmount(50_000_000n)).toBe(true) + }) + + it('returns true for MAX_STROOPS', () => { + expect(isValidStroopAmount(MAX_STROOPS)).toBe(true) + }) + + it('returns false for a negative bigint', () => { + expect(isValidStroopAmount(-1n)).toBe(false) + }) + + it('returns false for MAX_STROOPS + 1', () => { + expect(isValidStroopAmount(MAX_STROOPS + 1n)).toBe(false) + }) + + it('returns false for a plain number', () => { + expect(isValidStroopAmount(5)).toBe(false) + }) + + it('returns false for a string', () => { + expect(isValidStroopAmount('5')).toBe(false) + }) + + it('returns false for null', () => { + expect(isValidStroopAmount(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isValidStroopAmount(undefined)).toBe(false) + }) +}) + +// ─── Integration: reward amounts by difficulty ──────────────────────────────── + +describe('reward arithmetic integration', () => { + const BASE = 50_000_000n // 5 XLM for beginner + + it.each([ + ['beginner', [1n, 1n] as [bigint, bigint], 50_000_000n], // 5 XLM + ['intermediate', [3n, 2n] as [bigint, bigint], 75_000_000n], // 7.5 XLM + ['advanced', [2n, 1n] as [bigint, bigint], 100_000_000n], // 10 XLM + ['expert', [3n, 1n] as [bigint, bigint], 150_000_000n], // 15 XLM + ])('%s: multiplyStroops(%s, [%s]) === %s stroops', + (_diff, [num, den], expected) => { + expect(multiplyStroops(BASE, num, den)).toBe(expected) + }, + ) + + it('streak bonus for 3 days at beginner (5 XLM base) is 1.5 XLM', () => { + // 5 XLM × 3 × (1/10) = 1.5 XLM = 15_000_000 stroops + const streakBonus = multiplyStroops(BASE, 3n * 1n, 10n) + expect(streakBonus).toBe(15_000_000n) + }) + + it('streak bonus is capped at 100% of base (5 XLM max bonus for beginner)', () => { + const uncapped = multiplyStroops(BASE, 20n * 1n, 10n) // 200% + const capped = clampStroops(uncapped, BASE) + expect(capped).toBe(BASE) + expect(stroopsToXlmString(capped)).toBe('5.0000000') + }) + + it('total reward (beginner, 5-day streak, referral) is exactly correct', () => { + // base = 5 XLM + const base = multiplyStroops(BASE, 1n, 1n) + // streak = 5 × 10% × 5 XLM = 2.5 XLM + const streak = clampStroops(multiplyStroops(base, 5n, 10n), base) + // referral = 2 XLM + const referral = 20_000_000n + const total = addStroops(addStroops(base, streak), referral) + + expect(stroopsToXlmString(total)).toBe('9.5000000') // 5 + 2.5 + 2 + }) +}) diff --git a/tests/unit/reward.controller.test.ts b/tests/unit/reward.controller.test.ts index fca88aa..3955f5f 100644 --- a/tests/unit/reward.controller.test.ts +++ b/tests/unit/reward.controller.test.ts @@ -57,10 +57,11 @@ describe('RewardController', () => { describe('getBalance', () => { it('should return balance for authenticated user', async () => { + // Balance uses BigInt stroops internally; controller serialises to XLM strings const mockBalance = { - available: 100.5, - pending: 10, - lifetime: 150, + availableStroops: 1_005_000_000n, // 100.5 XLM + pendingStroops: 100_000_000n, // 10 XLM + lifetimeStroops: 1_500_000_000n, // 150 XLM updatedAt: new Date(), } @@ -79,9 +80,10 @@ describe('RewardController', () => { success: true, data: expect.objectContaining({ balance: { - available: 100.5, - pending: 10, - lifetime: 150, + // Amounts are serialised to 7-decimal XLM strings at the API boundary + available: '100.5000000', + pending: '10.0000000', + lifetime: '150.0000000', }, }), }), @@ -112,7 +114,7 @@ describe('RewardController', () => { id: 'txn-1', type: 'module_reward', status: 'completed', - amount: 5, + amountStroops: 50_000_000n, // 5 XLM in stroops createdAt: new Date(), }, ], @@ -302,7 +304,7 @@ describe('RewardController', () => { it('should process valid withdrawal request', async () => { const withdrawalData = { walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - amount: 50, + amount: '50.0000000', // XLM string (new API) memo: 'Test withdrawal', } @@ -311,7 +313,7 @@ describe('RewardController', () => { processWithdrawalSpy.mockResolvedValue({ transactionId: 'txn-withdrawal-123', userId: 'user-123', - amount: 50, + amountStroops: 500_000_000n, // 50 XLM in stroops stellarTxHash: 'stellar-hash-xyz', status: 'completed', requestedAt: new Date(), @@ -321,12 +323,13 @@ describe('RewardController', () => { await controller.withdraw(mockRequest as any, mockResponse as any, nextFn) - expect(hasSufficientBalanceSpy).toHaveBeenCalledWith('user-123', 50) + // Controller converts XLM string → stroops and passes BigInt to service + expect(hasSufficientBalanceSpy).toHaveBeenCalledWith('user-123', 500_000_000n) expect(processWithdrawalSpy).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123', walletAddress: withdrawalData.walletAddress, - amount: 50, + amountStroops: 500_000_000n, memo: 'Test withdrawal', }), ) @@ -373,11 +376,14 @@ describe('RewardController', () => { await controller.withdraw(mockRequest as any, mockResponse as any, nextFn) expect(nextFn).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Amount must be a valid number' }), + expect.objectContaining({ + message: expect.stringContaining('Invalid amount'), + }), ) }) it('should reject withdrawal if amount is zero or negative', async () => { + // amount: 0 → parses to 0n stroops → "Amount must be greater than 0" mockRequest.body = { walletAddress: 'GABC1234567890123456789012345678901234567890123456789', amount: 0, @@ -390,6 +396,7 @@ describe('RewardController', () => { expect.objectContaining({ message: 'Amount must be greater than 0' }), ) + // amount: -10 → toFixed(7) → "-10.0000000" → MoneyError (negative) → "Invalid amount" mockRequest.body = { walletAddress: 'GABC1234567890123456789012345678901234567890123456789', amount: -10, @@ -398,7 +405,9 @@ describe('RewardController', () => { await controller.withdraw(mockRequest as any, mockResponse as any, nextFn) expect(nextFn).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Amount must be greater than 0' }), + expect.objectContaining({ + message: expect.stringMatching(/Amount must be greater than 0|Invalid amount/), + }), ) }) @@ -421,14 +430,15 @@ describe('RewardController', () => { it('should reject withdrawal if insufficient balance', async () => { mockRequest.body = { walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - amount: 1000, + amount: '1000.0000000', } hasSufficientBalanceSpy.mockReturnValue(false) + // getBalance is called to format the error message — return BigInt stroops getBalanceSpy.mockReturnValue({ - available: 50, - pending: 0, - lifetime: 100, + availableStroops: 500_000_000n, // 50 XLM + pendingStroops: 0n, + lifetimeStroops: 1_000_000_000n, }) const nextFn = createNextFunction() @@ -444,7 +454,7 @@ describe('RewardController', () => { it('should handle withdrawal failure gracefully', async () => { mockRequest.body = { walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - amount: 50, + amount: '50.0000000', // XLM string (new API) } hasSufficientBalanceSpy.mockReturnValue(true) diff --git a/tests/unit/reward.service.test.ts b/tests/unit/reward.service.test.ts index 5f7646e..f4ccd3c 100644 --- a/tests/unit/reward.service.test.ts +++ b/tests/unit/reward.service.test.ts @@ -2,14 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { RewardService, DIFFICULTY_MULTIPLIERS, - BASE_REWARD_XLM, + BASE_REWARD_STROOPS, + REFERRAL_BONUS_STROOPS, STREAK_BONUS_RATE, MAX_STREAK_BONUS, - REFERRAL_BONUS_XLM, Module, RewardClaim, } from '../../src/services/reward.service' import { StellarService } from '../../src/services/stellar.service' +import { stroopsToXlmString } from '../../src/utils/money' // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -17,7 +18,7 @@ const makeModule = (overrides: Partial = {}): Module => ({ id: 'mod-001', title: 'Intro to Stellar', difficulty: 'beginner', - baseReward: BASE_REWARD_XLM, + baseRewardStroops: BASE_REWARD_STROOPS, // 5 XLM = 50_000_000 stroops ...overrides, }) @@ -53,76 +54,99 @@ describe('RewardService', () => { service._resetState() }) - // ── calculateReward ───────────────────────────────────────────────────────── + // ── calculateReward – base amounts by difficulty ─────────────────────────── describe('calculateReward – base amounts by difficulty', () => { it.each([ - ['beginner', 5], - ['intermediate', 7.5], - ['advanced', 10], - ['expert', 15], - ] as const)('%s difficulty yields %d XLM base', (difficulty, expected) => { - const { baseAmount } = service.calculateReward(makeModule({ difficulty })) - expect(baseAmount).toBe(expected) + ['beginner', 50_000_000n], // 5 XLM + ['intermediate', 75_000_000n], // 7.5 XLM + ['advanced', 100_000_000n], // 10 XLM + ['expert', 150_000_000n], // 15 XLM + ] as const)('%s difficulty yields correct stroops', (difficulty, expected) => { + const { baseAmountStroops } = service.calculateReward( + makeModule({ difficulty }), + ) + expect(baseAmountStroops).toBe(expected) }) it('applies the correct multiplier from DIFFICULTY_MULTIPLIERS', () => { - for (const [diff, mult] of Object.entries(DIFFICULTY_MULTIPLIERS)) { + for (const [diff, [num, den]] of Object.entries(DIFFICULTY_MULTIPLIERS)) { const mod = makeModule({ difficulty: diff as Module['difficulty'] }) - const { baseAmount } = service.calculateReward(mod) - expect(baseAmount).toBeCloseTo(BASE_REWARD_XLM * mult) + const { baseAmountStroops } = service.calculateReward(mod) + const expected = + (BASE_REWARD_STROOPS * num) / den + expect(baseAmountStroops).toBe(expected) } }) }) + // ── calculateReward – streak bonus ───────────────────────────────────────── + describe('calculateReward – streak bonus', () => { it('returns 0 streak bonus with 0 streak days', () => { - const { streakBonus } = service.calculateReward(makeModule(), 0) - expect(streakBonus).toBe(0) + const { streakBonusStroops } = service.calculateReward(makeModule(), 0) + expect(streakBonusStroops).toBe(0n) }) it('applies 10% bonus per streak day', () => { - const base = BASE_REWARD_XLM // beginner = 5 XLM - const { streakBonus } = service.calculateReward(makeModule(), 3) - // 3 days × 10% × 5 = 1.5 - expect(streakBonus).toBeCloseTo(base * 3 * STREAK_BONUS_RATE) + // beginner base = 50_000_000 stroops (5 XLM) + // 3 days × 10% × 5 XLM = 1.5 XLM = 15_000_000 stroops + const { streakBonusStroops } = service.calculateReward(makeModule(), 3) + expect(streakBonusStroops).toBe(15_000_000n) }) it('caps streak bonus at 100% of base', () => { - const base = BASE_REWARD_XLM - // 20 days would be 200% without a cap - const { streakBonus } = service.calculateReward(makeModule(), 20) - expect(streakBonus).toBeCloseTo(base * MAX_STREAK_BONUS) - }) - - it('streak bonus is included in totalAmount', () => { - const { baseAmount, streakBonus, totalAmount } = service.calculateReward( + // 20 days would be 200% without cap + const { streakBonusStroops, baseAmountStroops } = service.calculateReward( makeModule(), - 5, + 20, ) - expect(totalAmount).toBeCloseTo(baseAmount + streakBonus) + expect(streakBonusStroops).toBe(baseAmountStroops) // capped at 100% + }) + + it('streak bonus is included in totalAmountStroops', () => { + const { baseAmountStroops, streakBonusStroops, totalAmountStroops } = + service.calculateReward(makeModule(), 5) + expect(totalAmountStroops).toBe(baseAmountStroops + streakBonusStroops) + }) + + it('streak bonus for 5-day streak at beginner is 2.5 XLM (25_000_000 stroops)', () => { + const { streakBonusStroops } = service.calculateReward(makeModule(), 5) + expect(streakBonusStroops).toBe(25_000_000n) }) }) + // ── calculateReward – referral bonus ─────────────────────────────────────── + describe('calculateReward – referral bonus', () => { - it('adds REFERRAL_BONUS_XLM when hasReferral is true', () => { - const { referralBonus } = service.calculateReward(makeModule(), 0, true) - expect(referralBonus).toBe(REFERRAL_BONUS_XLM) + it('adds REFERRAL_BONUS_STROOPS when hasReferral is true', () => { + const { referralBonusStroops } = service.calculateReward( + makeModule(), + 0, + true, + ) + expect(referralBonusStroops).toBe(REFERRAL_BONUS_STROOPS) // 20_000_000n (2 XLM) }) it('adds no referral bonus when hasReferral is false', () => { - const { referralBonus } = service.calculateReward(makeModule(), 0, false) - expect(referralBonus).toBe(0) + const { referralBonusStroops } = service.calculateReward( + makeModule(), + 0, + false, + ) + expect(referralBonusStroops).toBe(0n) }) - it('totalAmount includes base + streak + referral', () => { - const { baseAmount, streakBonus, referralBonus, totalAmount } = + it('totalAmountStroops includes base + streak + referral', () => { + const { baseAmountStroops, streakBonusStroops, referralBonusStroops, totalAmountStroops } = service.calculateReward(makeModule(), 3, true) - expect(totalAmount).toBeCloseTo(baseAmount + streakBonus + referralBonus) + expect(totalAmountStroops).toBe( + baseAmountStroops + streakBonusStroops + referralBonusStroops, + ) }) }) - // ── claimReward ───────────────────────────────────────────────────────────── + // ── claimReward – happy path ──────────────────────────────────────────────── describe('claimReward – happy path', () => { it('returns a result with correct shape', async () => { @@ -137,31 +161,50 @@ describe('RewardService', () => { }) expect(result.transactionId).toMatch(/^txn_/) expect(result.claimedAt).toBeInstanceOf(Date) + // All amounts are BigInt + expect(typeof result.baseAmountStroops).toBe('bigint') + expect(typeof result.totalAmountStroops).toBe('bigint') }) - it('calls Stellar sendPayment with correct address and total amount', async () => { + it('calls Stellar sendPayment with 7-decimal XLM string (not a number)', async () => { const module = makeModule({ difficulty: 'advanced' }) const claim = makeClaim({ streakDays: 2 }) await service.claimReward(claim, module) - const { totalAmount } = service.calculateReward(module, 2, false) + const { totalAmountStroops } = service.calculateReward(module, 2, false) expect(stellarMock.sendPayment).toHaveBeenCalledWith( expect.objectContaining({ destinationPublicKey: claim.walletAddress, - amount: totalAmount.toString(), + // Amount must be a 7-decimal XLM string — never a number + amount: stroopsToXlmString(totalAmountStroops), memo: expect.stringContaining(claim.moduleId), }), ) }) + it('the amount passed to sendPayment is a string', async () => { + await service.claimReward(makeClaim(), makeModule()) + const call = (stellarMock.sendPayment as ReturnType).mock + .calls[0][0] + expect(typeof call.amount).toBe('string') + }) + it('records a transaction after successful claim', async () => { await service.claimReward(makeClaim(), makeModule()) const txns = service.getUserTransactions('user-abc') expect(txns).toHaveLength(1) expect(txns[0].type).toBe('module_reward') }) + + it('recorded transaction amount is a bigint', async () => { + await service.claimReward(makeClaim(), makeModule()) + const [txn] = service.getUserTransactions('user-abc') + expect(typeof txn.amountStroops).toBe('bigint') + }) }) + // ── claimReward – double-claim prevention ────────────────────────────────── + describe('claimReward – double-claim prevention', () => { it('throws when the same user claims the same module twice', async () => { const module = makeModule() @@ -196,33 +239,31 @@ describe('RewardService', () => { }) }) - // ── Streak bonus in claim ─────────────────────────────────────────────────── + // ── Streak bonus in claim ────────────────────────────────────────────────── describe('claimReward – streak bonus integration', () => { it('includes streak bonus in the result', async () => { const module = makeModule() const claim = makeClaim({ streakDays: 5 }) const result = await service.claimReward(claim, module) - expect(result.streakBonus).toBeGreaterThan(0) + expect(result.streakBonusStroops).toBeGreaterThan(0n) }) - it('passes correct totalAmount (with streak) to Stellar', async () => { + it('passes correct totalAmountStroops (with streak) to Stellar as XLM string', async () => { const module = makeModule() const claim = makeClaim({ streakDays: 5 }) await service.claimReward(claim, module) - const { totalAmount } = service.calculateReward(module, 5, false) + const { totalAmountStroops } = service.calculateReward(module, 5, false) expect(stellarMock.sendPayment).toHaveBeenCalledWith( expect.objectContaining({ - destinationPublicKey: claim.walletAddress, - amount: totalAmount.toString(), - memo: expect.any(String), + amount: stroopsToXlmString(totalAmountStroops), }), ) }) }) - // ── Referral rewards ──────────────────────────────────────────────────────── + // ── Referral rewards ─────────────────────────────────────────────────────── describe('claimReward – referral rewards', () => { const REFERRAL_CODE = 'REF-XYZ' @@ -232,46 +273,39 @@ describe('RewardService', () => { service.registerReferralCode(REFERRAL_CODE, REFERRER_ID) }) - it('pays the referrer a bonus when a valid referral code is used', async () => { - // Note: Currently referral bonus is skipped due to missing wallet address storage - // This test documents the expected behavior once wallet storage is implemented + it('pays the learner a reward when a valid referral code is used', async () => { const claim = makeClaim({ referralCode: REFERRAL_CODE }) await service.claimReward(claim, makeModule()) - // Currently only learner payment is made (referral bonus is skipped) + // Currently only learner payment is made (referral bonus skipped — no wallet storage yet) expect(stellarMock.sendPayment).toHaveBeenCalledTimes(1) }) - it('records a referral_reward transaction for the referrer', async () => { - // Note: Currently referral bonus is not recorded due to skipped payment - // This test will pass once wallet address storage is implemented + it('records a module_reward transaction for the learner', async () => { const claim = makeClaim({ referralCode: REFERRAL_CODE }) await service.claimReward(claim, makeModule()) - // Currently no referral transaction is recorded - const referrerTxns = service.getUserTransactions(REFERRER_ID) - expect(referrerTxns).toHaveLength(0) + const txns = service.getUserTransactions(claim.userId) + expect(txns).toHaveLength(1) + expect(txns[0].type).toBe('module_reward') }) it('does not pay referral bonus for an unknown referral code', async () => { const claim = makeClaim({ referralCode: 'UNKNOWN' }) await service.claimReward(claim, makeModule()) - // Only the learner payout — no referral payment expect(stellarMock.sendPayment).toHaveBeenCalledTimes(1) }) - it('still completes learner reward even if referral payout fails', async () => { - // Note: Currently referral is skipped entirely, so this test verifies learner reward works + it('still completes learner reward even if referral is skipped', async () => { const claim = makeClaim({ referralCode: REFERRAL_CODE }) const result = await service.claimReward(claim, makeModule()) - // The learner result should still be valid expect(result.stellarTxHash).toBe(MOCK_TX_HASH) }) }) - // ── registerReferralCode ──────────────────────────────────────────────────── + // ── registerReferralCode ─────────────────────────────────────────────────── describe('registerReferralCode', () => { it('registers a new code without throwing', () => { @@ -288,7 +322,7 @@ describe('RewardService', () => { }) }) - // ── Transaction records ───────────────────────────────────────────────────── + // ── Transaction records ──────────────────────────────────────────────────── describe('transaction records', () => { it('getTransactions returns all transactions', async () => { @@ -338,4 +372,133 @@ describe('RewardService', () => { expect(txn.stellarTxHash).toBe(MOCK_TX_HASH) }) }) + + // ── getBalance ──────────────────────────────────────────────────────────── + + describe('getBalance', () => { + it('returns zero balances for a user with no transactions', () => { + const balance = service.getBalance('user-new') + expect(balance.availableStroops).toBe(0n) + expect(balance.pendingStroops).toBe(0n) + expect(balance.lifetimeStroops).toBe(0n) + }) + + it('lifetimeStroops increases after a reward claim', async () => { + await service.claimReward(makeClaim(), makeModule()) + const balance = service.getBalance('user-abc') + expect(balance.lifetimeStroops).toBeGreaterThan(0n) + }) + + it('all balance amounts are BigInt', async () => { + await service.claimReward(makeClaim(), makeModule()) + const balance = service.getBalance('user-abc') + expect(typeof balance.availableStroops).toBe('bigint') + expect(typeof balance.pendingStroops).toBe('bigint') + expect(typeof balance.lifetimeStroops).toBe('bigint') + }) + + it('available equals lifetime when no withdrawals', async () => { + await service.claimReward(makeClaim(), makeModule()) + const balance = service.getBalance('user-abc') + expect(balance.availableStroops).toBe(balance.lifetimeStroops) + }) + }) + + // ── hasSufficientBalance ────────────────────────────────────────────────── + + describe('hasSufficientBalance', () => { + it('returns false for a user with no balance', () => { + expect( + service.hasSufficientBalance('user-empty', 10_000_000n), + ).toBe(false) + }) + + it('returns true after earning a reward and requesting ≤ available', async () => { + await service.claimReward(makeClaim(), makeModule()) + const balance = service.getBalance('user-abc') + expect( + service.hasSufficientBalance('user-abc', balance.availableStroops), + ).toBe(true) + }) + + it('returns false when requesting more than available', async () => { + await service.claimReward(makeClaim(), makeModule()) + const balance = service.getBalance('user-abc') + expect( + service.hasSufficientBalance( + 'user-abc', + balance.availableStroops + 1n, + ), + ).toBe(false) + }) + }) + + // ── processWithdrawal ───────────────────────────────────────────────────── + + describe('processWithdrawal', () => { + const WALLET = 'GABC1234567890123456789012345678901234567890123456789' + + it('throws when amount is 0', async () => { + await expect( + service.processWithdrawal({ + userId: 'user-abc', + walletAddress: WALLET, + amountStroops: 0n, + }), + ).rejects.toThrow(/greater than 0/) + }) + + it('throws when user has insufficient balance', async () => { + await expect( + service.processWithdrawal({ + userId: 'user-abc', + walletAddress: WALLET, + amountStroops: 10_000_000n, // 1 XLM — no balance + }), + ).rejects.toThrow(/insufficient balance/i) + }) + + it('sends XLM string (not a number) to Stellar for withdrawal', async () => { + // Fund the account first + await service.claimReward(makeClaim(), makeModule()) + const balance = service.getBalance('user-abc') + + await service.processWithdrawal({ + userId: 'user-abc', + walletAddress: WALLET, + amountStroops: balance.availableStroops, + }) + + // The second call is the withdrawal + const calls = (stellarMock.sendPayment as ReturnType).mock + .calls + const withdrawalCall = calls[calls.length - 1][0] + expect(typeof withdrawalCall.amount).toBe('string') + expect(withdrawalCall.amount).toBe( + stroopsToXlmString(balance.availableStroops), + ) + }) + }) +}) + +// ─── Numeric constants (kept for display / backward-compat) ────────────────── + +describe('numeric display constants', () => { + it('BASE_REWARD_XLM is 5 (numeric display value)', async () => { + const { BASE_REWARD_XLM } = await import('../../src/services/reward.service') + expect(BASE_REWARD_XLM).toBe(5) + }) + + it('REFERRAL_BONUS_XLM is 2 (numeric display value)', async () => { + const { REFERRAL_BONUS_XLM } = await import('../../src/services/reward.service') + expect(REFERRAL_BONUS_XLM).toBe(2) + }) + + it('STREAK_BONUS_RATE is 0.1 (numeric display value)', () => { + expect(STREAK_BONUS_RATE).toBe(0.1) + }) + + it('MAX_STREAK_BONUS is 1.0 (numeric display value)', () => { + expect(MAX_STREAK_BONUS).toBe(1.0) + }) })