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 00000000..b7d23053 --- /dev/null +++ b/apps/api/prisma/migrations/20260820093000_quote_rate_decimal_string/migration.sql @@ -0,0 +1,11 @@ +-- Store exchange rates as decimal strings so quote arithmetic and API responses +-- never round through JavaScript floats. PostgreSQL's text cast can emit +-- scientific notation for existing DOUBLE PRECISION values; to_char reconciles +-- existing rows into a plain decimal representation before the column becomes +-- TEXT. +ALTER TABLE "Quote" + ALTER COLUMN "rate" TYPE TEXT + USING CASE + WHEN "rate" IS NULL THEN NULL + ELSE trim(trailing '.' FROM trim(trailing '0' FROM to_char("rate", 'FM999999999999999999999999999999990.999999999999999999999999999999'))) + END; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2141a8a1..9eaa42f2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -61,9 +61,11 @@ model Transaction { id String @id @default(cuid()) userId String type String + /// Canonical decimal string validated by src/utils/money.js; never serialize monetary values as floats. amount String asset String @default("USDC") fiatCurrency String? + /// Canonical decimal string for configured fiat currencies. fiatAmount String? rail String @default("unknown") routeType String @default("unknown") @@ -219,9 +221,13 @@ model Quote { userId String? sourceCurrency String targetCurrency String + /// Canonical decimal string in sourceCurrency precision. sourceAmount String + /// Canonical decimal string in targetCurrency precision. targetAmount String? - rate Float? + /// Decimal string exchange rate; do not use Float for quote math. + rate String? + /// Canonical decimal string in sourceCurrency precision. 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 d8bc4c2e..a81bf2b8 100644 --- a/apps/api/src/compliance/compliance.service.js +++ b/apps/api/src/compliance/compliance.service.js @@ -2,12 +2,13 @@ const config = require('../config/env'); const prisma = require('../common/prisma'); const logger = require('../utils/logger'); const smileId = require('./smileId.provider'); +const { assertValidAmount, add, compare, formatUnits, getAssetRule } = require('../utils/money'); 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) }, + 1: { daily: process.env.TIER_1_DAILY_LIMIT || '50000.00', single: process.env.TIER_1_SINGLE_LIMIT || '20000.00' }, + 2: { daily: process.env.TIER_2_DAILY_LIMIT || '500000.00', single: process.env.TIER_2_SINGLE_LIMIT || '200000.00' }, + 3: { daily: process.env.TIER_3_DAILY_LIMIT || '5000000.00', single: process.env.TIER_3_SINGLE_LIMIT || '1000000.00' }, }; const SANCTIONS_BLOCKED_COUNTRIES = new Set(['KP', 'IR', 'SY', 'CU', 'SD', 'SDN']); @@ -185,10 +186,10 @@ const processSmileIdCallback = async (payload) => { const cryptoHash = (value) => require('crypto').createHash('sha256').update(value).digest('hex'); -const calculateRiskScore = ({ amount, routeType, destinationCountry, profileRiskScore = 0 }) => { +const calculateRiskScore = ({ amount, asset = 'NGN', routeType, destinationCountry, profileRiskScore = 0 }) => { let score = 10; - if (Number(amount) > 100000) score += 30; - if (Number(amount) > 50000) score += 10; + if (compare(assertValidAmount(amount, asset), '100000.00', asset) > 0) score += 30; + if (compare(assertValidAmount(amount, asset), '50000.00', asset) > 0) score += 10; if (routeType === 'cross_border') score += 25; if (destinationCountry && destinationCountry !== 'NG') score += 15; score += Math.min(Math.max(Number(profileRiskScore) || 0, 0), 30); @@ -223,10 +224,11 @@ const screenSanctions = ({ destinationCountry, routeType }) => { }; }; -const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCountry, tx = prisma }) => { +const enforceTransactionPolicy = async ({ user, amount, asset = 'NGN', routeType, destinationCountry, tx = prisma }) => { const profile = await getOrCreateKycProfile(user); const limits = tierLimits[profile.tier] || tierLimits[0]; - const parsedAmount = Number(amount); + const policyAsset = asset; + const normalizedAmount = assertValidAmount(amount, policyAsset); if (profile.status !== 'approved') { throw new Error('KYC approval is required before sending money.'); @@ -244,7 +246,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 (compare(normalizedAmount, limits.single, policyAsset) > 0) { throw new Error(`This payment exceeds your tier ${profile.tier} single transaction limit.`); } @@ -252,13 +254,15 @@ const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCo const recent = await tx.transaction.findMany({ where: { userId: user.id, + asset: policyAsset, status: { in: ['success', 'processing', 'pending'] }, createdAt: { gte: since }, }, - select: { amount: true }, + select: { amount: true, asset: true }, }); - const dailyTotal = recent.reduce((sum, t) => sum + Number(t.amount || 0), 0); - if (dailyTotal + parsedAmount > limits.daily) { + const zeroAmount = formatUnits(0n, getAssetRule(policyAsset).precision); + const dailyTotal = recent.reduce((sum, t) => add(sum, t.amount || zeroAmount, policyAsset), zeroAmount); + if (compare(add(dailyTotal, normalizedAmount, policyAsset), limits.daily, policyAsset) > 0) { throw new Error(`This payment exceeds your tier ${profile.tier} daily limit.`); } @@ -282,7 +286,7 @@ const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCo throw new Error(`This payment requires manual compliance review: ${sanctionsResult.reason}`); } - const riskScore = calculateRiskScore({ amount, routeType, destinationCountry, profileRiskScore: updatedProfile.riskScore }); + const riskScore = calculateRiskScore({ amount: normalizedAmount, asset: policyAsset, routeType, destinationCountry, profileRiskScore: updatedProfile.riskScore }); if (riskScore >= 80) { throw new Error('This payment requires manual compliance review.'); } diff --git a/apps/api/src/config/env.js b/apps/api/src/config/env.js index 149f7d76..369cd55b 100644 --- a/apps/api/src/config/env.js +++ b/apps/api/src/config/env.js @@ -78,6 +78,10 @@ module.exports = { coinGeckoBaseUrl: process.env.COINGECKO_BASE_URL || 'https://api.coingecko.com/api/v3', coinGeckoApiKey: process.env.COINGECKO_API_KEY, exchangeRateApiKey: process.env.EXCHANGERATE_API_KEY, + supportedFiatCurrencies: (process.env.SUPPORTED_FIAT_CURRENCIES || 'NGN') + .split(',') + .map((currency) => currency.trim().toUpperCase()) + .filter(Boolean), }, compliance: { provider: process.env.KYC_PROVIDER || 'smileid', diff --git a/apps/api/src/payment/payment.orchestrator.js b/apps/api/src/payment/payment.orchestrator.js index b224d8dd..ba39ea32 100644 --- a/apps/api/src/payment/payment.orchestrator.js +++ b/apps/api/src/payment/payment.orchestrator.js @@ -6,12 +6,9 @@ const { enforceTransactionPolicy } = require('../compliance/compliance.service') const { markTransactionFailed } = require('./markFailed'); const prisma = require('../common/prisma'); const { withIdAlias } = require('../common/records'); +const { assertValidAmount, percentage } = require('../utils/money'); -const calculateFee = (amount) => { - const parsed = Number(amount); - if (!Number.isFinite(parsed)) return '0'; - return (parsed * 0.01).toFixed(2); -}; +const calculateFee = (amount, asset = 'XLM') => percentage(assertValidAmount(amount, asset), asset, 100); const buildReceipt = ({ transaction }) => { return { @@ -52,11 +49,13 @@ const executePayment = async ({ const effectiveAsset = asset || NATIVE_ASSET; const effectiveRouteType = routeType || (sourceCountry && destinationCountry && sourceCountry !== destinationCountry ? 'cross_border' : 'domestic'); + const normalizedAmount = assertValidAmount(amount, effectiveAsset); const { quote, transaction } = await (prisma.$transaction ? prisma.$transaction(async (tx) => { const comp = await enforceTransactionPolicy({ user: senderUser, - amount, + amount: normalizedAmount, + asset: effectiveAsset, routeType: effectiveRouteType, destinationCountry, tx, @@ -65,7 +64,7 @@ const executePayment = async ({ userId: senderUser.id, sourceCurrency: effectiveAsset, targetCurrency: effectiveAsset, - sourceAmount: amount, + sourceAmount: normalizedAmount, route: rail, provider: rail, }); @@ -73,7 +72,7 @@ const executePayment = async ({ data: { userId: senderUser.id, type: 'send', - amount: String(amount), + amount: normalizedAmount, asset: effectiveAsset, recipientPhoneNumber, destination, @@ -82,7 +81,7 @@ const executePayment = async ({ quoteId: q.id, status: 'processing', metadata: { - fee: calculateFee(amount), + fee: calculateFee(normalizedAmount, effectiveAsset), userHiddenRail: true, riskScore: comp.riskScore, }, @@ -92,7 +91,8 @@ const executePayment = async ({ }) : (async () => { const comp = await enforceTransactionPolicy({ user: senderUser, - amount, + amount: normalizedAmount, + asset: effectiveAsset, routeType: effectiveRouteType, destinationCountry, tx: prisma, @@ -101,7 +101,7 @@ const executePayment = async ({ userId: senderUser.id, sourceCurrency: effectiveAsset, targetCurrency: effectiveAsset, - sourceAmount: amount, + sourceAmount: normalizedAmount, route: rail, provider: rail, }); @@ -109,7 +109,7 @@ const executePayment = async ({ data: { userId: senderUser.id, type: 'send', - amount: String(amount), + amount: normalizedAmount, asset: effectiveAsset, recipientPhoneNumber, destination, @@ -118,7 +118,7 @@ const executePayment = async ({ quoteId: q.id, status: 'processing', metadata: { - fee: calculateFee(amount), + fee: calculateFee(normalizedAmount, effectiveAsset), userHiddenRail: true, riskScore: comp.riskScore, }, @@ -131,7 +131,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: normalizedAmount, 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 3e052e87..020eae9a 100644 --- a/apps/api/src/pricing/pricing.service.js +++ b/apps/api/src/pricing/pricing.service.js @@ -2,35 +2,53 @@ const axios = require('axios'); const config = require('../config/env'); const prisma = require('../common/prisma'); const { withIdAlias } = require('../common/records'); +const { assertValidAmount, percentage, convert, getAssetRule, subtract, decimalToRatio } = require('../utils/money'); + +const normalizeCurrency = (currency) => String(currency || '').trim().toUpperCase(); + +const assertConfiguredCurrency = (currency) => { + const code = normalizeCurrency(currency); + getAssetRule(code); + if (!['XLM', 'USDC'].includes(code) && !(config.pricing?.supportedFiatCurrencies || ['NGN']).includes(code)) { + throw new Error(`Unsupported fiat currency: ${code}. Configure SUPPORTED_FIAT_CURRENCIES to enable it.`); + } + return code; +}; const getExchangeRate = async ({ sourceCurrency = 'NGN', targetCurrency = 'USDC' }) => { - if (sourceCurrency === targetCurrency) return 1; + sourceCurrency = assertConfiguredCurrency(sourceCurrency); + targetCurrency = assertConfiguredCurrency(targetCurrency); + if (sourceCurrency === targetCurrency) return '1'; - if (!config.pricing.exchangeRateApiKey) { + if (!config.pricing?.exchangeRateApiKey) { return null; } - const response = await axios.get(`https://v6.exchangerate-api.com/v6/${config.pricing.exchangeRateApiKey}/pair/${sourceCurrency}/${targetCurrency}`, { + 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; + if (response.data?.conversion_rate == null) return null; + return decimalToRatio(response.data.conversion_rate).decimal; }; const createQuote = async ({ userId, sourceCurrency = 'NGN', targetCurrency = 'USDC', sourceAmount, route, provider }) => { + sourceCurrency = assertConfiguredCurrency(sourceCurrency); + targetCurrency = assertConfiguredCurrency(targetCurrency); + const normalizedSourceAmount = assertValidAmount(sourceAmount, sourceCurrency); 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 fee = percentage(normalizedSourceAmount, sourceCurrency, 100); + const netSourceAmount = subtract(normalizedSourceAmount, fee, sourceCurrency); + const targetAmount = rate ? convert({ amount: netSourceAmount, sourceAsset: sourceCurrency, targetAsset: targetCurrency, rate }) : undefined; const quote = await prisma.quote.create({ data: { userId, sourceCurrency, targetCurrency, - sourceAmount: String(sourceAmount), + sourceAmount: normalizedSourceAmount, targetAmount, rate, - fee: feeAmount.toFixed(2), + fee, provider, route, expiresAt: new Date(Date.now() + 5 * 60 * 1000), @@ -42,4 +60,5 @@ const createQuote = async ({ userId, sourceCurrency = 'NGN', targetCurrency = 'U module.exports = { createQuote, getExchangeRate, + assertConfiguredCurrency, }; diff --git a/apps/api/src/utils/money.js b/apps/api/src/utils/money.js new file mode 100644 index 00000000..92b40cef --- /dev/null +++ b/apps/api/src/utils/money.js @@ -0,0 +1,123 @@ +const ASSET_RULES = Object.freeze({ + XLM: { precision: 7, min: '0.0000001', max: '1000000000', rounding: 'HALF_UP' }, + USDC: { precision: 7, min: '0.0000001', max: '1000000000', rounding: 'HALF_UP' }, + NGN: { precision: 2, min: '1.00', max: '5000000000.00', rounding: 'HALF_UP' }, + USD: { precision: 2, min: '0.01', max: '1000000000.00', rounding: 'HALF_UP' }, + EUR: { precision: 2, min: '0.01', max: '1000000000.00', rounding: 'HALF_UP' }, + GBP: { precision: 2, min: '0.01', max: '1000000000.00', rounding: 'HALF_UP' }, +}); + +const DECIMAL_RE = /^(?:0|[1-9]\d*)(?:\.(\d+))?$/; +const EXPONENTIAL_RE = /^([+-]?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/; + +const getAssetRule = (asset = 'XLM') => { + const code = String(asset || '').trim().toUpperCase(); + const rule = ASSET_RULES[code]; + if (!rule) throw new Error(`Unsupported asset or currency: ${asset}`); + return { code, ...rule }; +}; + +const parseUnits = (value, precision, { rejectExcessPrecision = true } = {}) => { + const raw = String(value).trim(); + const match = raw.match(DECIMAL_RE); + if (!match) throw new Error('Amount must be a positive decimal string.'); + const fractional = match[1] || ''; + if (rejectExcessPrecision && fractional.length > precision) { + throw new Error(`Amount supports at most ${precision} decimal places.`); + } + const padded = fractional.padEnd(precision, '0').slice(0, precision); + return BigInt(`${raw.split('.')[0]}${padded}`); +}; + +const formatUnits = (units, precision) => { + const sign = units < 0n ? '-' : ''; + const abs = units < 0n ? -units : units; + const scale = 10n ** BigInt(precision); + const whole = abs / scale; + const frac = (abs % scale).toString().padStart(precision, '0'); + if (precision === 0) return `${sign}${whole}`; + return `${sign}${whole}.${frac}`; +}; + +const assertValidAmount = (value, asset = 'XLM') => { + const rule = getAssetRule(asset); + const units = parseUnits(value, rule.precision); + if (units <= 0n) throw new Error('Amount must be greater than zero.'); + if (units < parseUnits(rule.min, rule.precision)) throw new Error(`Amount is below the ${rule.code} minimum of ${rule.min}.`); + if (units > parseUnits(rule.max, rule.precision)) throw new Error(`Amount exceeds the ${rule.code} maximum of ${rule.max}.`); + return formatUnits(units, rule.precision); +}; + +const expandExponentialDecimal = (value) => { + const raw = String(value).trim(); + const match = raw.match(EXPONENTIAL_RE); + if (!match) return raw; + + const [, sign, whole, fractional = '', exponentText] = match; + const exponent = Number(exponentText); + const digits = `${whole}${fractional}`.replace(/^0+(?=\d)/, ''); + const decimalPlaces = fractional.length - exponent; + + if (decimalPlaces <= 0) { + return `${sign}${digits}${'0'.repeat(Math.abs(decimalPlaces))}`; + } + + if (decimalPlaces >= digits.length) { + return `${sign}0.${'0'.repeat(decimalPlaces - digits.length)}${digits}`; + } + + const splitAt = digits.length - decimalPlaces; + return `${sign}${digits.slice(0, splitAt)}.${digits.slice(splitAt)}`; +}; + +const decimalToRatio = (value) => { + const raw = expandExponentialDecimal(value); + const match = raw.match(DECIMAL_RE); + if (!match) throw new Error('Rate must be a positive decimal string.'); + const fractional = match[1] || ''; + const numerator = BigInt(`${raw.split('.')[0]}${fractional}`); + if (numerator <= 0n) throw new Error('Rate must be greater than zero.'); + return { numerator, denominator: 10n ** BigInt(fractional.length), decimal: raw }; +}; + +const multiplyRatio = (units, numerator, denominator) => { + const product = units * numerator; + const quotient = product / denominator; + const remainder = product % denominator; + return remainder * 2n >= denominator ? quotient + 1n : quotient; +}; + +const percentage = (amount, asset, basisPoints) => { + const rule = getAssetRule(asset); + const units = parseUnits(amount, rule.precision); + return formatUnits(multiplyRatio(units, BigInt(basisPoints), 10000n), rule.precision); +}; + +const convert = ({ amount, sourceAsset, targetAsset, rate }) => { + const source = getAssetRule(sourceAsset); + const target = getAssetRule(targetAsset); + const sourceUnits = parseUnits(amount, source.precision); + const { numerator, denominator } = decimalToRatio(rate); + const targetScale = 10n ** BigInt(target.precision); + const sourceScale = 10n ** BigInt(source.precision); + return formatUnits(multiplyRatio(sourceUnits * targetScale, numerator, denominator * sourceScale), target.precision); +}; + +const add = (left, right, asset) => { + const rule = getAssetRule(asset); + return formatUnits(parseUnits(left, rule.precision) + parseUnits(right, rule.precision), rule.precision); +}; + +const subtract = (left, right, asset) => { + const rule = getAssetRule(asset); + return formatUnits(parseUnits(left, rule.precision) - parseUnits(right, rule.precision), rule.precision); +}; + +const compare = (left, right, asset) => { + const rule = getAssetRule(asset); + const a = parseUnits(left, rule.precision); + const b = parseUnits(right, rule.precision); + return a === b ? 0 : a > b ? 1 : -1; +}; + +module.exports = { ASSET_RULES, getAssetRule, assertValidAmount, parseUnits, formatUnits, percentage, convert, add, subtract, compare, decimalToRatio, expandExponentialDecimal }; diff --git a/apps/api/src/utils/validators.js b/apps/api/src/utils/validators.js index 4c93401c..071875dd 100644 --- a/apps/api/src/utils/validators.js +++ b/apps/api/src/utils/validators.js @@ -6,9 +6,15 @@ 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 { assertValidAmount } = require('./money'); + +const isValidAmount = (amount, asset = 'XLM') => { + try { + assertValidAmount(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 3cdeb084..a8991972 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 { assertValidAmount } = 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 normalizedAmount = assertValidAmount(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: normalizedAmount, }), ) .setTimeout(30) diff --git a/apps/api/test/compliance.service.test.js b/apps/api/test/compliance.service.test.js index 61c7ad50..d42914cd 100644 --- a/apps/api/test/compliance.service.test.js +++ b/apps/api/test/compliance.service.test.js @@ -86,3 +86,18 @@ test('enforceTransactionPolicy rejects review sanctions destination', async () = ); assert.equal(updated.sanctionsStatus, 'review'); }); + + +test('enforceTransactionPolicy totals only transactions in the requested asset', async () => { + resetPrisma(); + let where; + prismaMock.kycProfile.findUnique = async () => ({ id: 'profile_5', userId: user.id, provider: 'smileid', tier: 1, status: 'approved', sanctionsStatus: 'cleared', custodyStatus: 'not_reviewed' }); + prismaMock.transaction.findMany = async (query) => { + where = query.where; + return [{ amount: '19999.0000000', asset: 'USDC' }]; + }; + + await enforceTransactionPolicy({ user, amount: '1.0000000', asset: 'USDC', routeType: 'domestic', destinationCountry: 'NG' }); + + assert.equal(where.asset, 'USDC'); +}); diff --git a/apps/api/test/money.test.js b/apps/api/test/money.test.js new file mode 100644 index 00000000..af43fe5f --- /dev/null +++ b/apps/api/test/money.test.js @@ -0,0 +1,32 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { assertValidAmount, add, percentage, convert, decimalToRatio, expandExponentialDecimal } = require('../src/utils/money'); + +test('decimal addition avoids binary floating-point artifacts', () => { + assert.equal(add('0.10', '0.20', 'USD'), '0.30'); +}); + +test('asset-specific precision is enforced before side effects', () => { + assert.equal(assertValidAmount('1.1234567', 'XLM'), '1.1234567'); + assert.throws(() => assertValidAmount('1.12345678', 'XLM'), /at most 7 decimal places/); + assert.throws(() => assertValidAmount('1.001', 'USD'), /at most 2 decimal places/); +}); + +test('fees use deterministic half-up integer decimal arithmetic', () => { + assert.equal(percentage('0.0000001', 'XLM', 100), '0.0000000'); + assert.equal(percentage('100.00', 'NGN', 100), '1.00'); +}); + +test('quote conversion rounds to target asset precision', () => { + assert.equal(convert({ amount: '0.30', sourceAsset: 'USD', targetAsset: 'USDC', rate: '1' }), '0.3000000'); + assert.equal(convert({ amount: '100.00', sourceAsset: 'NGN', targetAsset: 'USDC', rate: '0.000625' }), '0.0625000'); +}); + + +test('exchange rates parse exact decimal and exponential values as positive ratios', () => { + assert.equal(expandExponentialDecimal('6.25e-4'), '0.000625'); + assert.deepEqual(decimalToRatio('0.000625'), { numerator: 625n, denominator: 1000000n, decimal: '0.000625' }); + assert.deepEqual(decimalToRatio('6.25e-4'), { numerator: 625n, denominator: 1000000n, decimal: '0.000625' }); + assert.throws(() => decimalToRatio('-1'), /positive decimal/); + assert.throws(() => decimalToRatio('0'), /greater than zero/); +}); diff --git a/apps/api/test/payment.orchestrator.test.js b/apps/api/test/payment.orchestrator.test.js index ebd4d7d9..63586651 100644 --- a/apps/api/test/payment.orchestrator.test.js +++ b/apps/api/test/payment.orchestrator.test.js @@ -56,11 +56,11 @@ const baseInput = { sender, recipientPhoneNumber: '+2348000000002', destination: dest, - amount: '100', + amount: '100.0000000', asset: 'USDC', }; -const txRow = { id: 'tx_1', userId: 1, type: 'send', amount: '100', asset: 'USDC', rail: 'stellar', status: 'processing', metadata: { fee: '1.00', riskScore: 10 } }; +const txRow = { id: 'tx_1', userId: 1, type: 'send', amount: '100.0000000', asset: 'USDC', rail: 'stellar', status: 'processing', metadata: { fee: '1.00', riskScore: 10 } }; const wallet = { id: 'wallet_1', publicKey: dest, encryptedSecretKey: 'encrypted' }; const submitOk = { txHash: 'abc123', explorerUrl: 'https://stellar.expert/abc123' }; const successTx = { ...txRow, status: 'success', txHash: 'abc123', explorerUrl: 'https://stellar.expert/abc123' }; @@ -81,22 +81,22 @@ 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', 'USDC'), '1.0000000'); + assert.equal(calculateFee('250', 'NGN'), '2.50'); + assert.equal(calculateFee('0.0000001', 'XLM'), '0.0000000'); }); -test('calculateFee: handles non-numeric input gracefully', () => { - assert.equal(calculateFee('abc'), '0'); - assert.equal(calculateFee(undefined), '0'); +test('calculateFee: rejects invalid precision before side effects', () => { + assert.throws(() => calculateFee('abc', 'USDC'), /positive decimal/); + assert.throws(() => calculateFee('1.00000001', 'USDC'), /at most 7 decimal/); }); test('buildReceipt: shapes a receipt from a successful transaction', () => { - const tx = { id: 'tx_1', status: 'success', amount: '100', asset: 'USDC', rail: 'stellar', explorerUrl: 'https://stellar.expert/abc123' }; + const tx = { id: 'tx_1', status: 'success', amount: '100.0000000', asset: 'USDC', rail: 'stellar', explorerUrl: 'https://stellar.expert/abc123' }; assert.deepEqual(buildReceipt({ transaction: tx }), { transactionId: 'tx_1', status: 'success', - amount: '100', + amount: '100.0000000', asset: 'USDC', rail: 'stellar', receiptUrl: 'https://stellar.expert/abc123',