// safe-payment.ts
import { Conto, ContoError, type PaymentRequestInput } from '@conto_finance/sdk';
const conto = new Conto({
apiKey: process.env.CONTO_API_KEY!,
timeout: 15_000,
});
const maxAmount = Number(process.env.MAX_PAYMENT_AMOUNT ?? '50');
if (!Number.isFinite(maxAmount) || maxAmount <= 0) {
throw new Error('MAX_PAYMENT_AMOUNT must be a positive number');
}
const allowedRecipients = new Set(
(process.env.PAYMENT_RECIPIENT_ALLOWLIST ?? '')
.split(',')
.map((address) => address.trim().toLowerCase())
.filter(Boolean)
);
type PaymentCommand = Pick<PaymentRequestInput, 'amount' | 'recipientAddress' | 'purpose'>;
export function parsePayment(value: unknown): PaymentCommand {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Body must be an object');
}
const body = value as Record<string, unknown>;
const address = typeof body.recipientAddress === 'string' ? body.recipientAddress : '';
const purpose = typeof body.purpose === 'string' ? body.purpose.trim() : '';
if (
typeof body.amount !== 'number' ||
!Number.isFinite(body.amount) ||
body.amount <= 0 ||
body.amount > maxAmount ||
!/^0x[0-9a-fA-F]{40}$/.test(address) ||
!allowedRecipients.has(address.toLowerCase()) ||
purpose.length < 1 ||
purpose.length > 200
) {
throw new Error('Invalid amount, recipient, or purpose');
}
return { amount: body.amount, recipientAddress: address, purpose };
}
export async function submitPayment(input: PaymentCommand, idempotencyKey: string) {
const request = await conto.payments.request({ ...input, idempotencyKey });
if (request.status !== 'APPROVED') {
const statusCode =
request.status === 'DENIED' ? 403 : request.status === 'REQUIRES_APPROVAL' ? 202 : 409;
return {
statusCode,
body: { requestId: request.requestId, status: request.status, reasons: request.reasons },
};
}
try {
const result = await conto.payments.execute(request.requestId);
return {
statusCode: result.status === 'processing' ? 202 : 200,
body: result,
};
} catch (error) {
const deterministicClientError =
error instanceof ContoError &&
error.status >= 400 &&
error.status < 500 &&
![408, 429].includes(error.status);
if (deterministicClientError) throw error;
// Execution may have succeeded before a timeout, connection drop, 429, or 5xx.
const state = await conto.payments.status(request.requestId).catch(() => null);
const transaction = state?.transaction;
return {
statusCode:
transaction?.status === 'failed' ? 409 : transaction?.status === 'completed' ? 200 : 202,
body: {
requestId: request.requestId,
status: transaction?.status ?? 'UNKNOWN',
transaction,
message: 'Execution result was reconciled; do not blindly retry execute.',
},
};
}
}
export function mapPaymentError(error: unknown) {
if (error instanceof ContoError) {
if (error.code === 'ACTION_REQUIRED') {
return {
statusCode: 402,
body: {
error: error.code,
requestId: error.requestId,
actionUrl: error.actionUrl,
},
};
}
let statusCode = 502;
if (error.code === 'INVALID_INPUT') statusCode = 400;
if (['IDEMPOTENCY_CONFLICT', 'INSUFFICIENT_BALANCE'].includes(error.code)) statusCode = 409;
if (error.code === 'RATE_LIMITED') statusCode = 503;
return { statusCode, body: { error: error.code } };
}
return { statusCode: 502, body: { error: 'PAYMENT_SERVICE_ERROR' } };
}