325 lines
8.1 KiB
TypeScript
325 lines
8.1 KiB
TypeScript
import { create } from 'zustand';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
/**
|
|
* 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;
|
|
}
|