import { create } from 'zustand'; import { invoke } from '../lib/tauri'; /** * A recipient in a batch transaction */ export interface BatchRecipient { id: string; address: string; amount: number; // in SYN (human readable) amountSompi: number; // in sompi (internal) label?: string; isValid: boolean; error?: string; } /** * Batch transaction summary */ export interface BatchSummary { totalAmount: number; // in sompi totalAmountHuman: string; recipientCount: number; estimatedFee: number; // in sompi estimatedFeeHuman: string; totalWithFee: number; // in sompi totalWithFeeHuman: string; } /** * Created batch transaction */ interface BatchTransactionResponse { tx_hex: string; tx_id: string; total_sent: number; fee: number; recipient_count: number; } interface BatchSendState { // State recipients: BatchRecipient[]; summary: BatchSummary | null; isLoading: boolean; error: string | null; lastTxId: string | null; // Actions addRecipient: () => void; removeRecipient: (id: string) => void; updateRecipient: (id: string, updates: Partial) => void; clearRecipients: () => void; importFromCsv: (csv: string) => void; calculateSummary: () => void; // Async actions createBatchTransaction: (fee?: number) => Promise; signAndBroadcast: (txHex: string) => Promise; } // Generate unique ID let idCounter = 0; const generateId = () => `recipient-${++idCounter}-${Date.now()}`; // Convert SYN to sompi const synToSompi = (syn: number): number => Math.floor(syn * 100_000_000); // Convert sompi to SYN const sompiToSyn = (sompi: number): string => (sompi / 100_000_000).toFixed(8); // Validate address format const validateAddress = (address: string): boolean => { return address.startsWith('synor1') || address.startsWith('tsynor1'); }; export const useBatchSendStore = create()((set, get) => ({ // Initial state recipients: [ { id: generateId(), address: '', amount: 0, amountSompi: 0, isValid: false, }, ], summary: null, isLoading: false, error: null, lastTxId: null, // Add a new recipient row addRecipient: () => { set((state) => ({ recipients: [ ...state.recipients, { id: generateId(), address: '', amount: 0, amountSompi: 0, isValid: false, }, ], })); }, // Remove a recipient removeRecipient: (id) => { set((state) => { const newRecipients = state.recipients.filter((r) => r.id !== id); // Keep at least one recipient row if (newRecipients.length === 0) { return { recipients: [ { id: generateId(), address: '', amount: 0, amountSompi: 0, isValid: false, }, ], summary: null, }; } return { recipients: newRecipients }; }); get().calculateSummary(); }, // Update a recipient updateRecipient: (id, updates) => { set((state) => ({ recipients: state.recipients.map((r) => { if (r.id !== id) return r; const newRecipient = { ...r, ...updates }; // Validate and update if ('amount' in updates) { newRecipient.amountSompi = synToSompi(updates.amount || 0); } // Validate address const addressValid = validateAddress(newRecipient.address); const amountValid = newRecipient.amountSompi > 0; newRecipient.isValid = addressValid && amountValid; if (!addressValid && newRecipient.address) { newRecipient.error = 'Invalid address format'; } else if (!amountValid && newRecipient.amount > 0) { newRecipient.error = 'Amount must be greater than 0'; } else { newRecipient.error = undefined; } return newRecipient; }), })); get().calculateSummary(); }, // Clear all recipients clearRecipients: () => { set({ recipients: [ { id: generateId(), address: '', amount: 0, amountSompi: 0, isValid: false, }, ], summary: null, error: null, lastTxId: null, }); }, // Import recipients from CSV (address,amount format) importFromCsv: (csv) => { const lines = csv.trim().split('\n'); const recipients: BatchRecipient[] = []; for (const line of lines) { const [address, amountStr, label] = line.split(',').map((s) => s.trim()); const amount = parseFloat(amountStr) || 0; if (address) { const isValidAddress = validateAddress(address); const isValidAmount = amount > 0; recipients.push({ id: generateId(), address, amount, amountSompi: synToSompi(amount), label: label || undefined, isValid: isValidAddress && isValidAmount, error: !isValidAddress ? 'Invalid address' : !isValidAmount ? 'Invalid amount' : undefined, }); } } if (recipients.length > 0) { set({ recipients }); get().calculateSummary(); } }, // Calculate transaction summary calculateSummary: () => { const { recipients } = get(); const validRecipients = recipients.filter((r) => r.isValid); if (validRecipients.length === 0) { set({ summary: null }); return; } const totalAmount = validRecipients.reduce((sum, r) => sum + r.amountSompi, 0); // Estimate fee (roughly 1000 sompi per recipient as base) // This is a simplification - actual fee depends on tx size const estimatedFee = Math.max(1000, validRecipients.length * 500 + 500); set({ summary: { totalAmount, totalAmountHuman: `${sompiToSyn(totalAmount)} SYN`, recipientCount: validRecipients.length, estimatedFee, estimatedFeeHuman: `${sompiToSyn(estimatedFee)} SYN`, totalWithFee: totalAmount + estimatedFee, totalWithFeeHuman: `${sompiToSyn(totalAmount + estimatedFee)} SYN`, }, }); }, // Create a batch transaction (unsigned) createBatchTransaction: async (fee) => { const { recipients, summary } = get(); const validRecipients = recipients.filter((r) => r.isValid); if (validRecipients.length === 0) { throw new Error('No valid recipients'); } set({ isLoading: true, error: null }); try { // Convert to backend format const outputs = validRecipients.map((r) => ({ address: r.address, amount: r.amountSompi, })); const response = await invoke('create_batch_transaction', { outputs, fee: fee || summary?.estimatedFee || 1000, }); set({ lastTxId: response.tx_id, isLoading: false, }); return response.tx_hex; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create batch transaction'; set({ error: message, isLoading: false }); throw error; } }, // Sign and broadcast the transaction signAndBroadcast: async (txHex) => { set({ isLoading: true, error: null }); try { // Sign the transaction const signedHex = await invoke('sign_transaction', { txHex }); // Broadcast it const txId = await invoke('broadcast_transaction', { txHex: signedHex }); set({ lastTxId: txId, isLoading: false, }); return txId; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to broadcast transaction'; set({ error: message, isLoading: false }); throw error; } }, })); /** * Get valid recipient count */ export function useValidRecipientCount(): number { return useBatchSendStore((state) => state.recipients.filter((r) => r.isValid).length ); } /** * Check if batch is ready to send */ export function useIsBatchReady(): boolean { const { recipients, summary } = useBatchSendStore(); const validCount = recipients.filter((r) => r.isValid).length; return validCount > 0 && summary !== null; }