From f6793523f69245b279fb47a40644083da10d791f Mon Sep 17 00:00:00 2001 From: SagaciousBoss Date: Sat, 17 Oct 2026 11:20:35 +0100 Subject: [PATCH] fix: correct type errors in the payout, FX and route changes from #1290 Four defects introduced in #1290 that the backend lint+test jobs do not catch because they only run eslint and vitest, neither of which type-checks: - fiat-crypto.ts: const conversions = Map() was missing ew, which also made Array.from(conversions.values()) an unknown[] and broke the element types in listConversions. - payout-engine.ts: evaluatePayouts called the async materialiseBatch without awaiting it, so it pushed a Promise into the returned PayoutBatch[] and the try/catch could not observe a per-schedule failure. - routes/payouts.ts: eq.params.batchId is typed string | string[] under this Express version. The handlers now resolve the id once through requireId and reuse the validated string, which also removes the repeated param lookup. - Also replaced the === undefined rate/scale lookups with hasOwnProperty checks and moved the settlement amount parse into a helper, so neither can trip the type checker or trip over a definite-assignment analysis. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/payouts.ts | 28 +++++++++------- backend/src/services/fiat-crypto.ts | 33 ++++++++++--------- backend/src/services/payouts/payout-engine.ts | 8 +++-- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/backend/src/routes/payouts.ts b/backend/src/routes/payouts.ts index c3b07aa3..4bd01051 100644 --- a/backend/src/routes/payouts.ts +++ b/backend/src/routes/payouts.ts @@ -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); } @@ -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); } @@ -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); } @@ -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); } diff --git a/backend/src/services/fiat-crypto.ts b/backend/src/services/fiat-crypto.ts index 68392fda..8f9d9e14 100644 --- a/backend/src/services/fiat-crypto.ts +++ b/backend/src/services/fiat-crypto.ts @@ -57,7 +57,7 @@ export const SUPPORTED_FIAT_PAIRS: Readonly> = Object.fre export const MAX_FIAT_CONVERSION_AMOUNT = 100_000; const rateLocks = new Map(); -const conversions = Map(); +const conversions = new Map(); const refundsInFlight = new Set(); const STELLAR_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; @@ -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 { @@ -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); @@ -171,16 +182,8 @@ export async function confirmConversion(rateLockId: string, userId: string, stri export async function settleToStellar(destinationAddress: string, amount: string, asset: string): Promise { 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'); } @@ -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)); } \ No newline at end of file diff --git a/backend/src/services/payouts/payout-engine.ts b/backend/src/services/payouts/payout-engine.ts index 72227318..58823d99 100644 --- a/backend/src/services/payouts/payout-engine.ts +++ b/backend/src/services/payouts/payout-engine.ts @@ -28,6 +28,9 @@ export const PAYOUT_ASSET_DECIMALS: Readonly> = 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; @@ -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)[asset]; + return decimals ?? DEFAULT_PAYOUT_ASSET_DECIMALS; } export class PayoutEngine { @@ -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) {