Skip to content
Open
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
86 changes: 86 additions & 0 deletions frontend/src/components/PreflightReportPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<div className="card glass noise border border-danger/30 mt-4">
<Heading as="h3" size="xs" weight="bold" addlClassName="mb-3 flex items-center gap-2 text-danger">
<Icon.Warning size="sm" /> Preflight Validation Failed
</Heading>

{!report.orgHasSufficientXlm && (
<div className="mb-4 p-3 bg-danger/10 border border-danger/20 rounded">
<Text as="p" size="sm" weight="bold" addlClassName="text-danger">Organization Wallet Insufficient XLM</Text>
<Text as="p" size="xs" addlClassName="text-muted mt-1">
Required: {report.requiredXlm.toFixed(2)} XLM | Available: {report.orgXlmBalance.toFixed(2)} XLM
</Text>
</div>
)}

{report.employeeResults.filter(r => !r.success).length > 0 && (
<div className="mb-4">
<Text as="p" size="sm" weight="bold" addlClassName="mb-2">Employee Account Issues:</Text>
<ul className="text-sm space-y-2">
{report.employeeResults.filter(r => !r.success).map(emp => (
<li key={emp.id} className="p-3 bg-black/20 rounded border border-hi">
<div className="flex justify-between items-center mb-1">
<span className="font-bold">{emp.name}</span>
<span className="font-mono text-xs text-muted truncate max-w-[150px]">{emp.walletAddress || 'No Wallet Provided'}</span>
</div>
{!emp.accountExists && <div className="text-danger text-xs mt-1">• Account does not exist on-chain</div>}
{emp.missingTrustlines.length > 0 && (
<div className="text-danger text-xs mt-1">• Missing trustlines: {emp.missingTrustlines.join(', ')}</div>
)}
</li>
))}
</ul>
</div>
)}

<div className="flex gap-3 mt-4">
<Button size="sm" variant="secondary" onClick={handleDownloadCsv}>
Download Errors (CSV)
</Button>
<Button size="sm" variant="primary" onClick={onRetry}>
Retry Validation
</Button>
</div>
</div>
);
};
17 changes: 17 additions & 0 deletions frontend/src/hooks/useFeeEstimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,12 +49,24 @@ export function useFeeEstimation() {
return estimateBatchPaymentBudget(count);
}, []);

/**
* Validates preflight conditions (employer balance, employee trustlines/accounts)
*/
const validatePreflight = useCallback(async (
orgPublicKey: string,
batchConfig: BatchItem[]
): Promise<ValidationReport> => {
const feeEstimate = await estimateBatchPaymentBudget(batchConfig.length);
return validateBatchRequirements(orgPublicKey, batchConfig, feeEstimate.totalBudget);
}, []);

return {
feeRecommendation,
isLoading,
isError,
error,
refetch,
estimateBatch,
validatePreflight,
};
}
33 changes: 30 additions & 3 deletions frontend/src/pages/PayrollScheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface PayrollFormState {
frequency: 'weekly' | 'monthly';
startDate: string;
memo?: string;
walletAddress?: string;
}

const formatDate = (dateString: string) => {
Expand Down Expand Up @@ -53,6 +54,7 @@ const initialFormState: PayrollFormState = {
frequency: 'monthly',
startDate: '',
memo: '',
walletAddress: '',
};

export default function PayrollScheduler() {
Expand All @@ -69,6 +71,10 @@ export default function PayrollScheduler() {
const [nextRunDate, setNextRunDate] = useState<Date | null>(null);
const [contractError, setContractError] = useState<ContractErrorDetail | null>(null);

const { validatePreflight } = useFeeEstimation();
const [validationReport, setValidationReport] = useState<ValidationReport | null>(null);
const [isPreflighting, setIsPreflighting] = useState(false);

const [pendingClaims, setPendingClaims] = useState<PendingClaim[]>(() => {
const saved = localStorage.getItem('pending-claims');
if (saved) {
Expand Down Expand Up @@ -364,6 +370,18 @@ export default function PayrollScheduler() {
/>
</div>

<div className="md:col-span-2">
<Input
id="walletAddress"
fieldSize="md"
label="Employee Wallet Address (Optional)"
name="walletAddress"
value={formData.walletAddress || ''}
onChange={handleChange}
placeholder="Leave empty to trigger validation failure for testing"
/>
</div>

<div>
<Input
id="amount"
Expand Down Expand Up @@ -406,12 +424,14 @@ export default function PayrollScheduler() {
{!simulationPassed ? (
<Button
type="submit"
disabled={isSimulating}
disabled={isSimulating || isPreflighting}
variant="primary"
size="md"
isFullWidth
>
{isSimulating
{isPreflighting
? 'Running Preflight Checks...'
: isSimulating
? 'Simulating...'
: t('payroll.submit', 'Initialize and Validate')}
</Button>
Expand Down Expand Up @@ -446,6 +466,13 @@ export default function PayrollScheduler() {
}}
/>

{validationReport && validationReport.success === false && (
<PreflightReportPanel
report={validationReport}
onRetry={handleInitialize}
/>
)}

<div className="card glass noise h-fit">
<Heading as="h3" size="xs" weight="bold" addlClassName="mb-4 flex items-center gap-2">
<svg
Expand All @@ -471,7 +498,7 @@ export default function PayrollScheduler() {
weight="regular"
addlClassName="text-muted leading-relaxed mb-4"
>
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:
</Text>
<ul className="text-xs text-muted space-y-2 list-disc pl-4 font-medium">
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/services/stellarValidation.ts
Original file line number Diff line number Diff line change
@@ -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<ValidationReport> {
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,
};
}