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
28 changes: 16 additions & 12 deletions backend/src/routes/payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,12 @@ payoutsRouter.post(
idempotency(),
payoutDecisionLimiter,
asyncHandler(async (req, res) => {
const approver = toApprover(requireApprover(req));
const approver = toApprover(requireApprover(req));
const batchId = requireId(req.params.batchId, 'batchId');
try {
const approved = await payoutEngine.approveManualPayout(requireId(req.params.batchId, 'batchId'), approver);
const approved = await payoutEngine.approveManualPayout(batchId, approver);
if (!approved) throw new AppError(409, 'Batch is not awaiting approval', 'INVALID_PAYOUT_STATE');
res.json({ data: payoutEngine.getBatch(req.params.batchId) });
res.json({ data: payoutEngine.getBatch(batchId) });
} catch (error) {
throw asHttpError(error);
}
Expand All @@ -249,11 +250,12 @@ payoutsRouter.post(
idempotency(),
payoutDecisionLimiter,
asyncHandler(async (req, res) => {
const approver = toApprover(requireApprover(req));
const approver = toApprover(requireApprover(req));
const batchId = requireId(req.params.batchId, 'batchId');
try {
const rejected = await payoutEngine.rejectManualPayout(requireId(req.params.batchId, 'batchId'), approver);
const rejected = await payoutEngine.rejectManualPayout(batchId, approver);
if (!rejected) throw new AppError(409, 'Batch is not awaiting approval', 'INVALID_PAYOUT_STATE');
res.json({ data: payoutEngine.getBatch(req.params.batchId) });
res.json({ data: payoutEngine.getBatch(batchId) });
} catch (error) {
throw asHttpError(error);
}
Expand All @@ -265,11 +267,12 @@ payoutsRouter.post(
idempotency(),
payoutDecisionLimiter,
asyncHandler(async (req, res) => {
const approver = toApprover(requireApprover(req));
const approver = toApprover(requireApprover(req));
const batchId = requireId(req.params.batchId, 'batchId');
try {
const retried = await payoutEngine.retryFailedBatch(requireId(req.params.batchId, 'batchId'), approver);
const retried = await payoutEngine.retryFailedBatch(batchId, approver);
if (!retried) throw new AppError(409, 'Batch is not retryable', 'INVALID_PAYOUT_STATE');
res.json({ data: payoutEngine.getBatch(req.params.batchId) });
res.json({ data: payoutEngine.getBatch(batchId) });
} catch (error) {
throw asHttpError(error);
}
Expand All @@ -281,11 +284,12 @@ payoutsRouter.post(
idempotency(),
payoutDecisionLimiter,
asyncHandler(async (req, res) => {
const approver = toApprover(requireApprover(req));
const approver = toApprover(requireApprover(req));
const batchId = requireId(req.params.batchId, 'batchId');
try {
const released = await payoutEngine.releaseReservedFunds(requireId(req.params.batchId, 'batchId'), approver);
const released = await payoutEngine.releaseReservedFunds(batchId, approver);
if (!released) throw new AppError(409, 'Batch has no releasable reservation', 'INVALID_PAYOUT_STATE');
res.json({ data: payoutEngine.getBatch(req.params.batchId) });
res.json({ data: payoutEngine.getBatch(batchId) });
} catch (error) {
throw asHttpError(error);
}
Expand Down
33 changes: 18 additions & 15 deletions backend/src/services/fiat-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const SUPPORTED_FIAT_PAIRS: Readonly<Record<string, number>> = Object.fre
export const MAX_FIAT_CONVERSION_AMOUNT = 100_000;

const rateLocks = new Map<string, RateLock>();
const conversions = Map<string, ConversionRecord>();
const conversions = new Map<string, ConversionRecord>();
const refundsInFlight = new Set<string>();

const STELLAR_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
Expand All @@ -76,11 +76,11 @@ function pairKey(fromCurrency: string, toAsset: string): string {
* @throws Error `UNSUPPORTED_FIAT_PAIR` when the pair is not quoted.
*/
function getCurrentRate(fromCurrency: string, toAsset: string): number {
const rate = SUPPORTED_FIAT_PAIRS[pairKey(fromCurrency, toAsset)];
if (rate === undefined) {
throw new Error(`UNSUPPORTED_FIAT_PAIR: ${pairKey(fromCurrency, toAsset)} is not quoted`);
const key = pairKey(fromCurrency, toAsset);
if (!Object.prototype.hasOwnProperty.call(SUPPORTED_FIAT_PAIRS, key)) {
throw new Error(`UNSUPPORTED_FIAT_PAIR: ${key} is not quoted`);
}
return rate;
return SUPPORTED_FIAT_PAIRS[key];
}

function assertNonEmptyId(value: unknown, field: string): string {
Expand All @@ -94,6 +94,17 @@ function snapshot(record: ConversionRecord): ConversionRecord {
return { ...record };
}

/** Parses a positive settlement amount, collapsing every failure mode onto one sentinel. */
function parseSettlementAmount(amount: string): bigint {
try {
const minor = parseDecimalToMinor(amount, CRYPTO_DECIMALS);
if (minor > 0n) return minor;
} catch {
// Any malformed amount falls through to the sentinel below.
}
throw new Error('INVALID_SETTLEMENT_AMOUNT');
}

/** Converts a fiat amount into the exact crypto amount at `rate`, without float drift. */
export function convertFiatToCrypto(fiatAmount: number, rate: number, decimals: number = CRYPTO_DECIMALS): string {
const fiatMinor = parseDecimalToMinor(fiatAmount, FIAT_DECIMALS);
Expand Down Expand Up @@ -171,16 +182,8 @@ export async function confirmConversion(rateLockId: string, userId: string, stri

export async function settleToStellar(destinationAddress: string, amount: string, asset: string): Promise<string> {
assertNonEmptyId(destinationAddress, 'destination_address');
parseSettlementAmount(amount);

let amountMinor: bigint;
try {
amountMinor = parseDecimalToMinor(amount, CRYPTO_DECIMALS);
} catch {
throw new Error('INVALID_SETTLEMENT_AMOUNT');
}
if (amountMinor <= 0n) {
throw new Error('INVALID_SETTLEMENT_AMOUNT');
}
if (!STELLAR_ADDRESS_PATTERN.test(destinationAddress)) {
throw new Error('INVALID_DESTINATION_ADDRESS');
}
Expand Down Expand Up @@ -245,5 +248,5 @@ export function getRate(fromCurrency: string, toAsset: string): number {

/** True when the pair has a quoted rate. */
export function isSupportedFiatPair(fromCurrency: string, toAsset: string): boolean {
return SUPPORTED_FIAT_PAIRS[pairKey(fromCurrency, toAsset)] !== undefined;
return Object.prototype.hasOwnProperty.call(SUPPORTED_FIAT_PAIRS, pairKey(fromCurrency, toAsset));
}
8 changes: 6 additions & 2 deletions backend/src/services/payouts/payout-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const PAYOUT_ASSET_DECIMALS: Readonly<Record<PayoutAsset, number>> = Obje
EURC: 6,
});

/** Fallback scale for an asset with no entry above; unused for allowlisted assets. */
export const DEFAULT_PAYOUT_ASSET_DECIMALS = 6;

/** Engine-level ceiling on a single batch, independent of any approver's limit. */
export const DEFAULT_PAYOUT_MAX_BATCH_AMOUNT = 1_000_000;

Expand Down Expand Up @@ -208,7 +211,8 @@ function resolvePositiveLimit(value: number | undefined, fallback: number, code:
}

function assetDecimals(asset: string): number {
return PAYOUT_ASSET_DECIMALS[asset as PayoutAsset] ?? 6;
const decimals = (PAYOUT_ASSET_DECIMALS as Record<string, number | undefined>)[asset];
return decimals ?? DEFAULT_PAYOUT_ASSET_DECIMALS;
}

export class PayoutEngine {
Expand Down Expand Up @@ -314,7 +318,7 @@ export class PayoutEngine {

for (const schedule of this.schedules) {
try {
const created = this.materialiseBatch(schedule);
const created = await this.materialiseBatch(schedule);
if (!created) continue;
executedBatches.push(created);
} catch (error) {
Expand Down
Loading