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
104 changes: 104 additions & 0 deletions prisma/migrations/20260821000000_exact_monetary_storage/migration.sql
Original file line number Diff line number Diff line change
@@ -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(<float> * 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;
22 changes: 19 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
59 changes: 38 additions & 21 deletions src/controllers/reward.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -121,7 +124,6 @@ export class RewardController {
throw new UnauthorizedError('User ID not found')
}

// Parse query parameters
const filters: any = {}

if (req.query.type) {
Expand Down Expand Up @@ -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(),
Expand All @@ -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: []
Expand Down Expand Up @@ -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')
}
Expand All @@ -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,
})

Expand All @@ -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(),
Expand All @@ -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)
}
Expand Down
Loading
Loading