From a1f2c60ceb3331be8e7517bef29e806087830a07 Mon Sep 17 00:00:00 2001 From: majormaxx <125857575+Majormaxx@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:56:31 +0100 Subject: [PATCH] feat: implement preflight balance checks --- .../src/components/PreflightReportPanel.tsx | 86 ++++++++++++++ frontend/src/hooks/useFeeEstimation.ts | 17 +++ frontend/src/pages/PayrollScheduler.tsx | 73 ++++++++++-- frontend/src/services/stellarValidation.ts | 111 ++++++++++++++++++ 4 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/PreflightReportPanel.tsx create mode 100644 frontend/src/services/stellarValidation.ts diff --git a/frontend/src/components/PreflightReportPanel.tsx b/frontend/src/components/PreflightReportPanel.tsx new file mode 100644 index 00000000..90a40685 --- /dev/null +++ b/frontend/src/components/PreflightReportPanel.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import type { ValidationReport } from '../services/stellarValidation'; +import { Button, Heading, Text, Icon } from '@stellar/design-system'; + +interface Props { + report: ValidationReport; + onRetry: () => void; +} + +export const PreflightReportPanel: React.FC = ({ report, onRetry }) => { + if (report.success) return null; + + const handleDownloadCsv = () => { + const rows = [ + ['Employee Name', 'Wallet Address', 'Account Exists', 'Missing Trustlines', 'Status'], + ]; + + report.employeeResults.forEach((emp) => { + if (!emp.success) { + rows.push([ + emp.name, + emp.walletAddress || 'N/A', + emp.accountExists ? 'Yes' : 'No', + emp.missingTrustlines.join(' '), + 'Failed', + ]); + } + }); + + const csvContent = rows.map((r) => r.join(',')).join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'failed_preflight_checks.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( +
+ + Preflight Validation Failed + + + {!report.orgHasSufficientXlm && ( +
+ Organization Wallet Insufficient XLM + + Required: {report.requiredXlm.toFixed(2)} XLM | Available: {report.orgXlmBalance.toFixed(2)} XLM + +
+ )} + + {report.employeeResults.filter(r => !r.success).length > 0 && ( +
+ Employee Account Issues: +
    + {report.employeeResults.filter(r => !r.success).map(emp => ( +
  • +
    + {emp.name} + {emp.walletAddress || 'No Wallet Provided'} +
    + {!emp.accountExists &&
    • Account does not exist on-chain
    } + {emp.missingTrustlines.length > 0 && ( +
    • Missing trustlines: {emp.missingTrustlines.join(', ')}
    + )} +
  • + ))} +
+
+ )} + +
+ + +
+
+ ); +}; diff --git a/frontend/src/hooks/useFeeEstimation.ts b/frontend/src/hooks/useFeeEstimation.ts index 0183d9f9..e83e741e 100644 --- a/frontend/src/hooks/useFeeEstimation.ts +++ b/frontend/src/hooks/useFeeEstimation.ts @@ -15,6 +15,11 @@ import { type FeeRecommendation, type BatchBudgetEstimate, } from '../services/feeEstimation'; +import { + validateBatchRequirements, + type BatchItem, + type ValidationReport, +} from '../services/stellarValidation'; /** Query key used by React Query for cache management */ const FEE_ESTIMATION_QUERY_KEY = ['fee-estimation'] as const; @@ -44,6 +49,17 @@ export function useFeeEstimation() { return estimateBatchPaymentBudget(count); }, []); + /** + * Validates preflight conditions (employer balance, employee trustlines/accounts) + */ + const validatePreflight = useCallback(async ( + orgPublicKey: string, + batchConfig: BatchItem[] + ): Promise => { + const feeEstimate = await estimateBatchPaymentBudget(batchConfig.length); + return validateBatchRequirements(orgPublicKey, batchConfig, feeEstimate.totalBudget); + }, []); + return { feeRecommendation, isLoading, @@ -51,5 +67,6 @@ export function useFeeEstimation() { error, refetch, estimateBatch, + validatePreflight, }; } diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 6d9987c0..11824b64 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -10,6 +10,10 @@ import { useTranslation } from 'react-i18next'; import { Card, Heading, Text, Button, Input, Select } from '@stellar/design-system'; import { SchedulingWizard } from '../components/SchedulingWizard'; import { CountdownTimer } from '../components/CountdownTimer'; +import { useFeeEstimation } from '../hooks/useFeeEstimation'; +import type { ValidationReport } from '../services/stellarValidation'; +import { PreflightReportPanel } from '../components/PreflightReportPanel'; +import { Keypair } from '@stellar/stellar-sdk'; interface PayrollFormState { employeeName: string; @@ -17,6 +21,7 @@ interface PayrollFormState { frequency: 'weekly' | 'monthly'; startDate: string; memo?: string; + walletAddress?: string; } const formatDate = (dateString: string) => { @@ -48,6 +53,7 @@ const initialFormState: PayrollFormState = { frequency: 'monthly', startDate: '', memo: '', + walletAddress: '', }; export default function PayrollScheduler() { @@ -63,6 +69,10 @@ export default function PayrollScheduler() { } | null>(null); const [nextRunDate, setNextRunDate] = useState(null); + const { validatePreflight } = useFeeEstimation(); + const [validationReport, setValidationReport] = useState(null); + const [isPreflighting, setIsPreflighting] = useState(false); + const [pendingClaims, setPendingClaims] = useState(() => { const saved = localStorage.getItem('pending-claims'); if (saved) { @@ -150,11 +160,39 @@ export default function PayrollScheduler() { return; } - // Mock XDR for simulation demonstration - const mockXdr = - 'AAAAAgAAAABmF8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + setIsPreflighting(true); + setValidationReport(null); + + try { + const empKeypair = Keypair.fromSecret(MOCK_EMPLOYER_SECRET); + const mockBatch = [{ + id: Math.random().toString(36).substr(2, 9), + name: formData.employeeName, + walletAddress: formData.walletAddress || '', + amount: String(formData.amount), + assetCode: 'USDC', + }]; + + const report = await validatePreflight(empKeypair.publicKey(), mockBatch); + setValidationReport(report); + + if (!report.success) { + notifyError('Preflight validation failed', 'Please review the errors before simulating.'); + setIsPreflighting(false); + return; + } + setIsPreflighting(false); - await simulate({ envelopeXdr: mockXdr }); + // Mock XDR for simulation demonstration + const mockXdr = + 'AAAAAgAAAABmF8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + await simulate({ envelopeXdr: mockXdr }); + } catch (err) { + console.error(err); + notifyError('Preflight Error', 'An error occurred during preflight checks.'); + setIsPreflighting(false); + } }; const handleBroadcast = async () => { @@ -333,6 +371,18 @@ export default function PayrollScheduler() { /> +
+ +
+
- {isSimulating + {isPreflighting + ? 'Running Preflight Checks...' + : isSimulating ? 'Simulating...' : t('payroll.submit', 'Initialize and Validate')} @@ -410,6 +462,13 @@ export default function PayrollScheduler() { onReset={resetSimulation} /> + {validationReport && validationReport.success === false && ( + + )} +
- All transactions are simulated via Stellar Horizon before submission. This catches + All transactions undergo preflight checks and simulation via Stellar Horizon before submission. This catches common errors like:
    diff --git a/frontend/src/services/stellarValidation.ts b/frontend/src/services/stellarValidation.ts new file mode 100644 index 00000000..17cb8637 --- /dev/null +++ b/frontend/src/services/stellarValidation.ts @@ -0,0 +1,111 @@ +import { Horizon } from '@stellar/stellar-sdk'; + +const BASE_RESERVE = 0.5; // XLM + +export interface BatchItem { + id: string; + name: string; + walletAddress: string; + amount: string; + assetCode: string; +} + +export interface EmployeeValidationResult { + id: string; + name: string; + walletAddress: string; + accountExists: boolean; + missingTrustlines: string[]; + success: boolean; +} + +export interface ValidationReport { + orgHasSufficientXlm: boolean; + orgXlmBalance: number; + requiredXlm: number; + employeeResults: EmployeeValidationResult[]; + success: boolean; +} + +export async function validateBatchRequirements( + orgPublicKey: string, + batchConfig: BatchItem[], + estimatedFeesStroops: number, + horizonUrl?: string +): Promise { + const url = horizonUrl || import.meta.env.PUBLIC_STELLAR_HORIZON_URL?.replace(/\/+$/, '') || 'https://horizon-testnet.stellar.org'; + const server = new Horizon.Server(url); + + let orgBalance = 0; + try { + const orgAccount = await server.loadAccount(orgPublicKey); + const xlmBalance = orgAccount.balances.find((b) => b.asset_type === 'native'); + orgBalance = xlmBalance ? parseFloat(xlmBalance.balance) : 0; + } catch (err) { + // Org account might not exist + } + + // Base Reserve + Estimated Fees (converted to XLM) + const requiredXlm = BASE_RESERVE + (estimatedFeesStroops / 10000000); + const orgHasSufficientXlm = orgBalance >= requiredXlm; + + const employeeResults: EmployeeValidationResult[] = []; + + for (const item of batchConfig) { + let accountExists = false; + const missingTrustlines: string[] = []; + + if (!item.walletAddress || item.walletAddress.length < 50) { + employeeResults.push({ + id: item.id, + name: item.name, + walletAddress: item.walletAddress || '', + accountExists: false, + missingTrustlines: item.assetCode !== 'XLM' ? [item.assetCode] : [], + success: false + }); + continue; + } + + try { + const empAccount = await server.loadAccount(item.walletAddress); + accountExists = true; + + if (item.assetCode !== 'XLM') { + const hasTrustline = empAccount.balances.some((b) => + ('asset_code' in b && b.asset_code === item.assetCode) + ); + if (!hasTrustline) { + missingTrustlines.push(item.assetCode); + } + } + } catch { + accountExists = false; + if (item.assetCode !== 'XLM') { + missingTrustlines.push(item.assetCode); + } + } + + const success = accountExists && missingTrustlines.length === 0; + + employeeResults.push({ + id: item.id, + name: item.name, + walletAddress: item.walletAddress, + accountExists, + missingTrustlines, + success, + }); + } + + const allEmployeesSuccess = employeeResults.every((r) => r.success); + const success = orgHasSufficientXlm && allEmployeesSuccess; + + return { + orgHasSufficientXlm, + orgXlmBalance: orgBalance, + requiredXlm, + employeeResults, + success, + }; +}