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
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ model Quote {
targetCurrency String
sourceAmount String
targetAmount String?
rate Float?
rate String?
fee String?
provider String?
route String?
Expand Down
25 changes: 15 additions & 10 deletions apps/api/src/compliance/compliance.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.');
Expand All @@ -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.`);
}

Expand All @@ -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.`);
}

Expand Down
33 changes: 20 additions & 13 deletions apps/api/src/payment/payment.orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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,
Expand All @@ -65,15 +72,15 @@ const executePayment = async ({
userId: senderUser.id,
sourceCurrency: effectiveAsset,
targetCurrency: effectiveAsset,
sourceAmount: amount,
sourceAmount: settlementAmount,
route: rail,
provider: rail,
});
const t = await tx.transaction.create({
data: {
userId: senderUser.id,
type: 'send',
amount: String(amount),
amount: settlementAmount,
asset: effectiveAsset,
recipientPhoneNumber,
destination,
Expand All @@ -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,
},
Expand All @@ -92,7 +99,7 @@ const executePayment = async ({
}) : (async () => {
const comp = await enforceTransactionPolicy({
user: senderUser,
amount,
amount: settlementAmount,
routeType: effectiveRouteType,
destinationCountry,
tx: prisma,
Expand All @@ -101,15 +108,15 @@ const executePayment = async ({
userId: senderUser.id,
sourceCurrency: effectiveAsset,
targetCurrency: effectiveAsset,
sourceAmount: amount,
sourceAmount: settlementAmount,
route: rail,
provider: rail,
});
const t = await prisma.transaction.create({
data: {
userId: senderUser.id,
type: 'send',
amount: String(amount),
amount: settlementAmount,
asset: effectiveAsset,
recipientPhoneNumber,
destination,
Expand All @@ -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,
},
Expand All @@ -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: {
Expand Down
35 changes: 20 additions & 15 deletions apps/api/src/pricing/pricing.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions apps/api/src/utils/money.js
Original file line number Diff line number Diff line change
@@ -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 };
12 changes: 9 additions & 3 deletions apps/api/src/utils/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 3 additions & 5 deletions apps/api/src/wallet/stellar.adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -202,7 +200,7 @@ const submitPayment = async ({
StellarSdk.Operation.payment({
destination,
asset: resolveAsset(asset),
amount: amount.toString(),
amount: settlementAmount,
}),
)
.setTimeout(30)
Expand Down
Loading