synor/apps/desktop-wallet/src/store/batchSend.ts
2026-02-02 15:16:29 +05:30

325 lines
8.1 KiB
TypeScript

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<BatchRecipient>) => void;
clearRecipients: () => void;
importFromCsv: (csv: string) => void;
calculateSummary: () => void;
// Async actions
createBatchTransaction: (fee?: number) => Promise<string>;
signAndBroadcast: (txHex: string) => Promise<string>;
}
// 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<BatchSendState>()((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<BatchTransactionResponse>('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<string>('sign_transaction', { txHex });
// Broadcast it
const txId = await invoke<string>('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;
}