diff --git a/apps/api/prisma/migrations/20260820093000_quote_rate_decimal_string/migration.sql b/apps/api/prisma/migrations/20260820093000_quote_rate_decimal_string/migration.sql new file mode 100644 index 0000000..79fadc1 --- /dev/null +++ b/apps/api/prisma/migrations/20260820093000_quote_rate_decimal_string/migration.sql @@ -0,0 +1,3 @@ +-- Store exchange rates as decimal strings so quotes do not reintroduce +-- binary floating-point artifacts after exact money calculations. +ALTER TABLE "Quote" ALTER COLUMN "rate" TYPE TEXT USING "rate"::TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2141a8a..e8878a2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -221,7 +221,7 @@ model Quote { targetCurrency String sourceAmount String targetAmount String? - rate Float? + rate String? fee String? provider String? route String? diff --git a/apps/api/src/compliance/compliance.service.js b/apps/api/src/compliance/compliance.service.js index d8bc4c2..1c52bb8 100644 --- a/apps/api/src/compliance/compliance.service.js +++ b/apps/api/src/compliance/compliance.service.js @@ -4,10 +4,10 @@ const logger = require('../utils/logger'); const smileId = require('./smileId.provider'); const tierLimits = { - 0: { daily: 0, single: 0 }, - 1: { daily: Number(process.env.TIER_1_DAILY_LIMIT || 50000), single: Number(process.env.TIER_1_SINGLE_LIMIT || 20000) }, - 2: { daily: Number(process.env.TIER_2_DAILY_LIMIT || 500000), single: Number(process.env.TIER_2_SINGLE_LIMIT || 200000) }, - 3: { daily: Number(process.env.TIER_3_DAILY_LIMIT || 5000000), single: Number(process.env.TIER_3_SINGLE_LIMIT || 1000000) }, + 0: { daily: '0', single: '0' }, + 1: { daily: process.env.TIER_1_DAILY_LIMIT || '50000', single: process.env.TIER_1_SINGLE_LIMIT || '20000' }, + 2: { daily: process.env.TIER_2_DAILY_LIMIT || '500000', single: process.env.TIER_2_SINGLE_LIMIT || '200000' }, + 3: { daily: process.env.TIER_3_DAILY_LIMIT || '5000000', single: process.env.TIER_3_SINGLE_LIMIT || '1000000' }, }; const SANCTIONS_BLOCKED_COUNTRIES = new Set(['KP', 'IR', 'SY', 'CU', 'SD', 'SDN']); @@ -185,10 +185,14 @@ const processSmileIdCallback = async (payload) => { const cryptoHash = (value) => require('crypto').createHash('sha256').update(value).digest('hex'); +const { normalizeAmount, parseDecimal, compare, add } = require('../utils/money'); + +const exceeds = (amount, threshold) => compare(parseDecimal(amount, { allowExcessPrecision: true }), parseDecimal(threshold, { allowExcessPrecision: true })) > 0; + const calculateRiskScore = ({ amount, routeType, destinationCountry, profileRiskScore = 0 }) => { let score = 10; - if (Number(amount) > 100000) score += 30; - if (Number(amount) > 50000) score += 10; + if (exceeds(amount, '100000')) score += 30; + if (exceeds(amount, '50000')) score += 10; if (routeType === 'cross_border') score += 25; if (destinationCountry && destinationCountry !== 'NG') score += 15; score += Math.min(Math.max(Number(profileRiskScore) || 0, 0), 30); @@ -226,7 +230,7 @@ const screenSanctions = ({ destinationCountry, routeType }) => { const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCountry, tx = prisma }) => { const profile = await getOrCreateKycProfile(user); const limits = tierLimits[profile.tier] || tierLimits[0]; - const parsedAmount = Number(amount); + const parsedAmount = normalizeAmount(amount, 'XLM'); if (profile.status !== 'approved') { throw new Error('KYC approval is required before sending money.'); @@ -244,7 +248,7 @@ const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCo throw new Error('This account is under sanctions review and cannot send funds until cleared.'); } - if (parsedAmount > limits.single) { + if (exceeds(parsedAmount, String(limits.single))) { throw new Error(`This payment exceeds your tier ${profile.tier} single transaction limit.`); } @@ -257,8 +261,9 @@ const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCo }, select: { amount: true }, }); - const dailyTotal = recent.reduce((sum, t) => sum + Number(t.amount || 0), 0); - if (dailyTotal + parsedAmount > limits.daily) { + const dailyTotal = recent.reduce((sum, t) => add(sum, t.amount || '0', 7), '0.0000000'); + const projectedTotal = add(dailyTotal, parsedAmount, 7); + if (exceeds(projectedTotal, String(limits.daily))) { throw new Error(`This payment exceeds your tier ${profile.tier} daily limit.`); } diff --git a/apps/api/src/payment/payment.orchestrator.js b/apps/api/src/payment/payment.orchestrator.js index b224d8d..cfdf663 100644 --- a/apps/api/src/payment/payment.orchestrator.js +++ b/apps/api/src/payment/payment.orchestrator.js @@ -6,11 +6,17 @@ const { enforceTransactionPolicy } = require('../compliance/compliance.service') const { markTransactionFailed } = require('./markFailed'); const prisma = require('../common/prisma'); const { withIdAlias } = require('../common/records'); +const { normalizeAmount, multiply, policyFor } = require('../utils/money'); -const calculateFee = (amount) => { - const parsed = Number(amount); - if (!Number.isFinite(parsed)) return '0'; - return (parsed * 0.01).toFixed(2); +const FEE_RATE = '0.01'; + +const calculateFee = (amount, asset = 'XLM') => { + try { + const policy = policyFor(asset); + return multiply(normalizeAmount(amount, asset), FEE_RATE, policy.scale); + } catch (_error) { + return '0'; + } }; const buildReceipt = ({ transaction }) => { @@ -50,13 +56,14 @@ const executePayment = async ({ // Direct custody only supports the native asset for now (see // wallet/stellar.adapter.js resolveAsset) — no anchor-asset support yet. const effectiveAsset = asset || NATIVE_ASSET; + const settlementAmount = normalizeAmount(amount, effectiveAsset); const effectiveRouteType = routeType || (sourceCountry && destinationCountry && sourceCountry !== destinationCountry ? 'cross_border' : 'domestic'); const { quote, transaction } = await (prisma.$transaction ? prisma.$transaction(async (tx) => { const comp = await enforceTransactionPolicy({ user: senderUser, - amount, + amount: settlementAmount, routeType: effectiveRouteType, destinationCountry, tx, @@ -65,7 +72,7 @@ const executePayment = async ({ userId: senderUser.id, sourceCurrency: effectiveAsset, targetCurrency: effectiveAsset, - sourceAmount: amount, + sourceAmount: settlementAmount, route: rail, provider: rail, }); @@ -73,7 +80,7 @@ const executePayment = async ({ data: { userId: senderUser.id, type: 'send', - amount: String(amount), + amount: settlementAmount, asset: effectiveAsset, recipientPhoneNumber, destination, @@ -82,7 +89,7 @@ const executePayment = async ({ quoteId: q.id, status: 'processing', metadata: { - fee: calculateFee(amount), + fee: calculateFee(settlementAmount, effectiveAsset), userHiddenRail: true, riskScore: comp.riskScore, }, @@ -92,7 +99,7 @@ const executePayment = async ({ }) : (async () => { const comp = await enforceTransactionPolicy({ user: senderUser, - amount, + amount: settlementAmount, routeType: effectiveRouteType, destinationCountry, tx: prisma, @@ -101,7 +108,7 @@ const executePayment = async ({ userId: senderUser.id, sourceCurrency: effectiveAsset, targetCurrency: effectiveAsset, - sourceAmount: amount, + sourceAmount: settlementAmount, route: rail, provider: rail, }); @@ -109,7 +116,7 @@ const executePayment = async ({ data: { userId: senderUser.id, type: 'send', - amount: String(amount), + amount: settlementAmount, asset: effectiveAsset, recipientPhoneNumber, destination, @@ -118,7 +125,7 @@ const executePayment = async ({ quoteId: q.id, status: 'processing', metadata: { - fee: calculateFee(amount), + fee: calculateFee(settlementAmount, effectiveAsset), userHiddenRail: true, riskScore: comp.riskScore, }, @@ -131,7 +138,7 @@ const executePayment = async ({ try { const wallet = await walletService.createOrGetWallet({ user: senderUser }); - const result = await walletService.submitPayment({ wallet, destination, amount, asset: effectiveAsset }); + const result = await walletService.submitPayment({ wallet, destination, amount: settlementAmount, asset: effectiveAsset }); activeTransaction = await prisma.transaction.update({ where: { id: activeTransaction.id }, data: { diff --git a/apps/api/src/pricing/pricing.service.js b/apps/api/src/pricing/pricing.service.js index 3e052e8..33354e2 100644 --- a/apps/api/src/pricing/pricing.service.js +++ b/apps/api/src/pricing/pricing.service.js @@ -2,9 +2,10 @@ const axios = require('axios'); const config = require('../config/env'); const prisma = require('../common/prisma'); const { withIdAlias } = require('../common/records'); +const { normalizeAmount, multiply, subtract, policyFor } = require('../utils/money'); const getExchangeRate = async ({ sourceCurrency = 'NGN', targetCurrency = 'USDC' }) => { - if (sourceCurrency === targetCurrency) return 1; + if (sourceCurrency === targetCurrency) return '1'; if (!config.pricing.exchangeRateApiKey) { return null; @@ -13,27 +14,31 @@ const getExchangeRate = async ({ sourceCurrency = 'NGN', targetCurrency = 'USDC' const response = await axios.get(`https://v6.exchangerate-api.com/v6/${config.pricing.exchangeRateApiKey}/pair/${sourceCurrency}/${targetCurrency}`, { timeout: 15000, }); - return response.data?.conversion_rate || null; + const rate = response.data?.conversion_rate; + return rate == null ? null : String(rate); }; const createQuote = async ({ userId, sourceCurrency = 'NGN', targetCurrency = 'USDC', sourceAmount, route, provider }) => { const rate = await getExchangeRate({ sourceCurrency, targetCurrency }); - const numericAmount = Number(sourceAmount); - const feeAmount = Number.isFinite(numericAmount) ? numericAmount * 0.01 : 0; - const targetAmount = rate && Number.isFinite(numericAmount) ? ((numericAmount - feeAmount) * rate).toFixed(6) : undefined; + const sourcePolicy = policyFor(sourceCurrency); + const targetPolicy = policyFor(targetCurrency); + const normalizedSourceAmount = normalizeAmount(sourceAmount, sourceCurrency); + const feeAmount = multiply(normalizedSourceAmount, '0.01', sourcePolicy.scale); + const netAmount = subtract(normalizedSourceAmount, feeAmount, sourcePolicy.scale); + const targetAmount = rate ? multiply(netAmount, rate, targetPolicy.scale) : undefined; const quote = await prisma.quote.create({ data: { - userId, - sourceCurrency, - targetCurrency, - sourceAmount: String(sourceAmount), - targetAmount, - rate, - fee: feeAmount.toFixed(2), - provider, - route, - expiresAt: new Date(Date.now() + 5 * 60 * 1000), + userId, + sourceCurrency, + targetCurrency, + sourceAmount: normalizedSourceAmount, + targetAmount, + rate, + fee: feeAmount, + provider, + route, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), }, }); return withIdAlias(quote); diff --git a/apps/api/src/utils/money.js b/apps/api/src/utils/money.js new file mode 100644 index 0000000..2955f98 --- /dev/null +++ b/apps/api/src/utils/money.js @@ -0,0 +1,86 @@ +const ASSET_POLICIES = Object.freeze({ + XLM: { scale: 7, min: '0.0000001', max: '100000000000.0000000' }, + USDC: { scale: 7, min: '0.0000001', max: '100000000000.0000000' }, + NGN: { scale: 2, min: '0.01', max: '100000000000.00' }, + USD: { scale: 2, min: '0.01', max: '100000000000.00' }, + EUR: { scale: 2, min: '0.01', max: '100000000000.00' }, + GBP: { scale: 2, min: '0.01', max: '100000000000.00' }, +}); + +const ROUND_HALF_UP = 'HALF_UP'; +const ROUND_DOWN = 'DOWN'; +const MONEY_PATTERN = /^\+?(?:0|[1-9]\d*)(?:\.\d+)?$/; +const POW10 = Array.from({ length: 19 }, (_, i) => 10n ** BigInt(i)); + +const policyFor = (asset = 'XLM') => { + const policy = ASSET_POLICIES[String(asset).toUpperCase()]; + if (!policy) throw new Error(`Unsupported asset precision policy for ${asset}.`); + return policy; +}; + +const parseDecimal = (value, { scale, asset, allowExcessPrecision = false } = {}) => { + const raw = String(value ?? '').trim(); + if (!MONEY_PATTERN.test(raw)) throw new Error('Amount must be a positive decimal string.'); + const normalized = raw.startsWith('+') ? raw.slice(1) : raw; + const [whole, fraction = ''] = normalized.split('.'); + if (!allowExcessPrecision && scale != null && fraction.length > scale) { + throw new Error(`${asset || 'Amount'} supports at most ${scale} decimal places.`); + } + const targetScale = scale ?? fraction.length; + const padded = fraction.padEnd(targetScale, '0').slice(0, targetScale); + return { units: BigInt(whole + padded), scale: targetScale }; +}; + +const align = (a, b) => { + const scale = Math.max(a.scale, b.scale); + return [a.units * POW10[scale - a.scale], b.units * POW10[scale - b.scale], scale]; +}; + +const compare = (left, right) => { + const [a, b] = align(left, right); + return a === b ? 0 : (a > b ? 1 : -1); +}; + +const formatUnits = (units, scale) => { + const sign = units < 0n ? '-' : ''; + const abs = units < 0n ? -units : units; + if (scale === 0) return `${sign}${abs}`; + const raw = abs.toString().padStart(scale + 1, '0'); + return `${sign}${raw.slice(0, -scale)}.${raw.slice(-scale)}`; +}; + +const normalizeAmount = (value, asset = 'XLM') => { + const policy = policyFor(asset); + const parsed = parseDecimal(value, { scale: policy.scale, asset }); + if (parsed.units <= 0n) throw new Error('Amount must be greater than zero.'); + if (compare(parsed, parseDecimal(policy.min, { scale: policy.scale })) < 0) throw new Error(`${asset} amount is below the minimum ${policy.min}.`); + if (compare(parsed, parseDecimal(policy.max, { scale: policy.scale })) > 0) throw new Error(`${asset} amount exceeds the maximum ${policy.max}.`); + return formatUnits(parsed.units, policy.scale); +}; + +const add = (left, right, scale) => { + const [a, b, alignedScale] = align(parseDecimal(left, { allowExcessPrecision: true }), parseDecimal(right, { allowExcessPrecision: true })); + return round({ units: a + b, scale: alignedScale }, scale); +}; + +const subtract = (left, right, scale) => { + const [a, b, alignedScale] = align(parseDecimal(left, { allowExcessPrecision: true }), parseDecimal(right, { allowExcessPrecision: true })); + return round({ units: a - b, scale: alignedScale }, scale); +}; + +const multiply = (left, right, scale, mode = ROUND_HALF_UP) => { + const a = parseDecimal(left, { allowExcessPrecision: true }); + const b = parseDecimal(right, { allowExcessPrecision: true }); + return round({ units: a.units * b.units, scale: a.scale + b.scale }, scale, mode); +}; + +const round = (decimal, scale, mode = ROUND_HALF_UP) => { + if (decimal.scale <= scale) return formatUnits(decimal.units * POW10[scale - decimal.scale], scale); + const divisor = POW10[decimal.scale - scale]; + let quotient = decimal.units / divisor; + const remainder = decimal.units % divisor; + if (mode === ROUND_HALF_UP && remainder * 2n >= divisor) quotient += 1n; + return formatUnits(quotient, scale); +}; + +module.exports = { ASSET_POLICIES, ROUND_HALF_UP, ROUND_DOWN, policyFor, parseDecimal, normalizeAmount, compare, add, subtract, multiply, round, formatUnits }; diff --git a/apps/api/src/utils/validators.js b/apps/api/src/utils/validators.js index 4c93401..8bbb68b 100644 --- a/apps/api/src/utils/validators.js +++ b/apps/api/src/utils/validators.js @@ -2,13 +2,19 @@ // Stellar address validation lives in stellar.service (StrKey-based) so this // module stays free of SDK concerns; import isValidPublicKey from there. +const { normalizeAmount } = require('./money'); + const isValidPhoneNumber = (phone) => { return typeof phone === 'string' && phone.trim().length > 5; }; -const isValidAmount = (amount) => { - const parsed = Number(amount); - return Number.isFinite(parsed) && parsed > 0; +const isValidAmount = (amount, asset = 'XLM') => { + try { + normalizeAmount(amount, asset); + return true; + } catch (_error) { + return false; + } }; module.exports = { diff --git a/apps/api/src/wallet/stellar.adapter.js b/apps/api/src/wallet/stellar.adapter.js index 3cdeb08..26f1b44 100644 --- a/apps/api/src/wallet/stellar.adapter.js +++ b/apps/api/src/wallet/stellar.adapter.js @@ -4,6 +4,7 @@ const { server, StellarSdk } = require("../config/stellar"); const axios = require("axios"); const logger = require("../utils/logger"); const config = require("../config/env"); +const { normalizeAmount } = require("../utils/money"); const chain = "stellar"; @@ -167,10 +168,7 @@ const submitPayment = async ({ throw new Error("Destination must be a valid Stellar public key."); } - const parsedAmount = Number(amount); - if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - throw new Error("Amount must be greater than zero."); - } + const settlementAmount = normalizeAmount(amount, asset); const sourceKeypair = StellarSdk.Keypair.fromSecret(secretKey); const sourcePublicKey = sourceKeypair.publicKey(); @@ -202,7 +200,7 @@ const submitPayment = async ({ StellarSdk.Operation.payment({ destination, asset: resolveAsset(asset), - amount: amount.toString(), + amount: settlementAmount, }), ) .setTimeout(30) diff --git a/apps/api/test/money.test.js b/apps/api/test/money.test.js new file mode 100644 index 0000000..0876c49 --- /dev/null +++ b/apps/api/test/money.test.js @@ -0,0 +1,26 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { add, multiply, normalizeAmount } = require('../src/utils/money'); +const { isValidAmount } = require('../src/utils/validators'); + +test('decimal addition avoids binary floating-point artifacts', () => { + assert.equal(add('0.1', '0.2', 7), '0.3000000'); +}); + +test('asset precision is enforced before side effects', () => { + assert.equal(normalizeAmount('1.2345678', 'XLM'), '1.2345678'); + assert.throws(() => normalizeAmount('1.23456789', 'XLM'), /at most 7 decimal places/); + assert.equal(isValidAmount('10.12', 'NGN'), true); + assert.equal(isValidAmount('10.123', 'NGN'), false); +}); + +test('fees use deterministic half-up decimal rounding at asset scale', () => { + assert.equal(multiply('250', '0.01', 7), '2.5000000'); + assert.equal(multiply('0.005', '1', 2), '0.01'); +}); + +test('high value boundaries are exact', () => { + assert.equal(normalizeAmount('100000000000.0000000', 'USDC'), '100000000000.0000000'); + assert.throws(() => normalizeAmount('100000000000.0000001', 'USDC'), /exceeds the maximum/); +}); diff --git a/apps/api/test/payment.orchestrator.test.js b/apps/api/test/payment.orchestrator.test.js index ebd4d7d..b5486dd 100644 --- a/apps/api/test/payment.orchestrator.test.js +++ b/apps/api/test/payment.orchestrator.test.js @@ -81,9 +81,9 @@ const setUpHappyPath = () => { // Pure-export unit tests // --------------------------------------------------------------------------- test('calculateFee: returns 1% of the amount', () => { - assert.equal(calculateFee('100'), '1.00'); - assert.equal(calculateFee('250'), '2.50'); - assert.equal(calculateFee('0'), '0.00'); + assert.equal(calculateFee('100'), '1.0000000'); + assert.equal(calculateFee('250'), '2.5000000'); + assert.equal(calculateFee('0'), '0'); }); test('calculateFee: handles non-numeric input gracefully', () => { diff --git a/apps/api/test/pricing.decimal.test.js b/apps/api/test/pricing.decimal.test.js new file mode 100644 index 0000000..61f920c --- /dev/null +++ b/apps/api/test/pricing.decimal.test.js @@ -0,0 +1,61 @@ +const { test, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const injectMock = (relativeFromSrc, exports) => { + const abs = path.resolve(__dirname, '../src', `${relativeFromSrc}.js`); + require.cache[abs] = { + id: abs, + filename: abs, + loaded: true, + exports, + }; +}; + +const createdQuotes = []; +const axiosGet = mock.fn(); + +injectMock('config/env', { pricing: { exchangeRateApiKey: 'test-key' } }); +injectMock('common/prisma', { + quote: { + create: mock.fn(async ({ data }) => { + createdQuotes.push(data); + return { id: 'quote_1', ...data }; + }), + }, +}); +injectMock('common/records', { withIdAlias: (record) => ({ ...record, _id: record.id }) }); +require.cache[require.resolve('axios')] = { + id: require.resolve('axios'), + filename: require.resolve('axios'), + loaded: true, + exports: { get: axiosGet }, +}; + +const { createQuote, getExchangeRate } = require('../src/pricing/pricing.service'); + +test('getExchangeRate returns decimal strings, including same-currency rates', async () => { + assert.equal(await getExchangeRate({ sourceCurrency: 'USDC', targetCurrency: 'USDC' }), '1'); + + axiosGet.mock.mockImplementationOnce(async () => ({ data: { conversion_rate: '1600.1234567' } })); + assert.equal(await getExchangeRate({ sourceCurrency: 'USDC', targetCurrency: 'NGN' }), '1600.1234567'); +}); + +test('createQuote calculates fee, net, and target amount with exact decimals', async () => { + createdQuotes.length = 0; + axiosGet.mock.mockImplementationOnce(async () => ({ data: { conversion_rate: '1' } })); + + const quote = await createQuote({ + userId: 'user_1', + sourceCurrency: 'USDC', + targetCurrency: 'USDC', + sourceAmount: '0.30', + route: 'stellar', + provider: 'stellar', + }); + + assert.equal(quote.sourceAmount, '0.3000000'); + assert.equal(quote.fee, '0.0030000'); + assert.equal(quote.targetAmount, '0.2970000'); + assert.equal(createdQuotes[0].rate, '1'); +});