Skip to main content

Batch Payments Guide

Complete guide to sending payments to multiple recipients efficiently on the Hoosat blockchain.

Understanding Spam Protection​

Hoosat inherits spam protection from Kaspa, which limits transaction outputs:

Hard Limits:

  • Maximum 2 recipient outputs per transaction
  • Maximum 3 total outputs (2 recipients + 1 change)

Why?

  • Prevents network spam and bloat
  • Maintains fast block times
  • Ensures efficient transaction processing

Solution: Batch multiple transactions to send to 3+ recipients.

Basic Batch Payment​

Sending to 3 Recipients​

import {
HoosatClient,
HoosatCrypto,
HoosatTxBuilder,
HoosatUtils
} from 'hoosat-sdk';

interface Payment {
address: string;
amount: string; // in sompi
}

async function sendToThreeRecipients() {
const client = new HoosatClient({
host: '54.38.176.95',
port: 42420
});

const wallet = HoosatCrypto.importKeyPair(
process.env.WALLET_PRIVATE_KEY!,
'mainnet'
);

const payments: Payment[] = [
{
address: 'hoosat:qz95mwas8ja7ucsernv9z335rdxxqswff7wvzenl29qukn5qs3lsqfsa4pd74',
amount: HoosatUtils.amountToSompi('1.0')
},
{
address: 'hoosat:qzk8h2q7wn9p3j5m6x4r8t5v3w9y2k4m7p8q6r9t3v5w8x2z4b7c9d',
amount: HoosatUtils.amountToSompi('0.5')
},
{
address: 'hoosat:qpm4n7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2g3h4i5j6k7l8m9n0',
amount: HoosatUtils.amountToSompi('0.25')
}
];

// Get UTXOs
const utxosResult = await client.getUtxosByAddresses([wallet.address]);
let availableUtxos = utxosResult.result.utxos;

const txIds: string[] = [];

// Transaction 1: Recipients 1 & 2
{
const batch = payments.slice(0, 2);
console.log('Sending batch 1/2...');

const builder = new HoosatTxBuilder();

// Add UTXOs
for (const utxo of availableUtxos) {
builder.addInput(utxo, wallet.privateKey);
}

// Add recipients
for (const payment of batch) {
builder.addOutput(payment.address, payment.amount);
}

// Calculate minimum fee and add change
const minFee = HoosatCrypto.calculateMinFee(
availableUtxos.length,
3 // 2 recipients + 1 change
);

builder.setFee(minFee);
builder.addChangeOutput(wallet.address);

// Submit
const signedTx = builder.sign();
const result = await client.submitTransaction(signedTx);

if (result.ok) {
txIds.push(result.result.transactionId);
console.log('Batch 1 sent:', result.result.transactionId);
} else {
throw new Error(`Batch 1 failed: ${result.error}`);
}

// Wait for confirmation and refresh UTXOs
await new Promise(resolve => setTimeout(resolve, 5000));
const newUtxos = await client.getUtxosByAddresses([wallet.address]);
availableUtxos = newUtxos.result.utxos;
}

// Transaction 2: Recipient 3
{
const batch = payments.slice(2, 3);
console.log('Sending batch 2/2...');

const builder = new HoosatTxBuilder();

// Add UTXOs
for (const utxo of availableUtxos) {
builder.addInput(utxo, wallet.privateKey);
}

// Add recipient
for (const payment of batch) {
builder.addOutput(payment.address, payment.amount);
}

// Calculate minimum fee and add change
const minFee = HoosatCrypto.calculateMinFee(
availableUtxos.length,
2 // 1 recipient + 1 change
);

builder.setFee(minFee);
builder.addChangeOutput(wallet.address);

// Submit
const signedTx = builder.sign();
const result = await client.submitTransaction(signedTx);

if (result.ok) {
txIds.push(result.result.transactionId);
console.log('Batch 2 sent:', result.result.transactionId);
} else {
throw new Error(`Batch 2 failed: ${result.error}`);
}
}

client.disconnect();

console.log('\nAll payments sent!');
console.log('Transaction IDs:', txIds);

return txIds;
}

Advanced Batch Payment System​

Smart Batching Strategy​

class BatchPaymentProcessor {
private client: HoosatClient;
private wallet: KeyPair;
private batchSize: number = 2; // Max recipients per tx

constructor(client: HoosatClient, wallet: KeyPair) {
this.client = client;
this.wallet = wallet;
}

async sendBatch(payments: Payment[]): Promise<BatchResult> {
console.log(`Processing ${payments.length} payments...`);

const result: BatchResult = {
successful: [],
failed: [],
totalFees: 0n
};

// Get initial UTXOs
let utxos = await this.getUtxos();

// Process in batches of 2
for (let i = 0; i < payments.length; i += this.batchSize) {
const batch = payments.slice(i, i + this.batchSize);
const batchNum = Math.floor(i / this.batchSize) + 1;
const totalBatches = Math.ceil(payments.length / this.batchSize);

console.log(`\nBatch ${batchNum}/${totalBatches} (${batch.length} recipients)`);

try {
// Select UTXOs for this batch
const selectedUtxos = await this.selectUtxos(utxos, batch);

if (selectedUtxos.length === 0) {
throw new Error('Insufficient UTXOs for batch');
}

// Build transaction
const txId = await this.sendBatchTransaction(selectedUtxos, batch);

// Record success
for (const payment of batch) {
result.successful.push({
...payment,
txId,
timestamp: new Date()
});
}

console.log(`✓ Batch ${batchNum} sent: ${txId}`);

// Wait for confirmation before next batch
if (i + this.batchSize < payments.length) {
console.log('Waiting for confirmation...');
await this.waitForConfirmation(txId);

// Refresh UTXOs
utxos = await this.getUtxos();
}

} catch (error) {
console.error(`✗ Batch ${batchNum} failed:`, error.message);

// Record failures
for (const payment of batch) {
result.failed.push({
...payment,
error: error.message,
timestamp: new Date()
});
}
}
}

console.log('\n=== Batch Payment Summary ===');
console.log(`Successful: ${result.successful.length}`);
console.log(`Failed: ${result.failed.length}`);
console.log(`Total fees: ${HoosatUtils.sompiToAmount(result.totalFees)} HTN`);
console.log('============================\n');

return result;
}

private async getUtxos(): Promise<UtxoForSigning[]> {
const result = await this.client.getUtxosByAddresses([this.wallet.address]);

if (!result.ok) {
throw new Error('Failed to get UTXOs');
}

return result.result.utxos;
}

private async selectUtxos(
availableUtxos: UtxoForSigning[],
batch: Payment[]
): Promise<UtxoForSigning[]> {
// Calculate total needed
const totalAmount = batch.reduce(
(sum, p) => sum + BigInt(p.amount),
0n
);

// Estimate fee
const estimatedFee = 100000n; // Rough estimate
const needed = totalAmount + estimatedFee;

// Select UTXOs (largest first)
const sorted = [...availableUtxos].sort((a, b) => {
const amountA = BigInt(a.utxoEntry.amount);
const amountB = BigInt(b.utxoEntry.amount);
return amountA > amountB ? -1 : 1;
});

const selected: UtxoForSigning[] = [];
let total = 0n;

for (const utxo of sorted) {
selected.push(utxo);
total += BigInt(utxo.utxoEntry.amount);

if (total >= needed) {
break;
}
}

if (total < needed) {
throw new Error(
`Insufficient funds. Need ${HoosatUtils.sompiToAmount(needed)} HTN, ` +
`have ${HoosatUtils.sompiToAmount(total)} HTN`
);
}

return selected;
}

private async sendBatchTransaction(
utxos: UtxoForSigning[],
payments: Payment[]
): Promise<string> {
const builder = new HoosatTxBuilder();

// Add inputs
for (const utxo of utxos) {
builder.addInput(utxo, this.wallet.privateKey);
}

// Add outputs
for (const payment of payments) {
builder.addOutput(payment.address, payment.amount);
}

// Calculate minimum fee
const minFee = HoosatCrypto.calculateMinFee(
utxos.length,
payments.length + 1 // recipients + change
);

builder.setFee(minFee);
builder.addChangeOutput(this.wallet.address);

// Submit
const signedTx = builder.sign();
const result = await this.client.submitTransaction(signedTx);

if (!result.ok) {
throw new Error(result.error || 'Transaction failed');
}

return result.result.transactionId;
}

private async waitForConfirmation(txId: string): Promise<void> {
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000));

const result = await this.client.getTransactionStatus(txId);

if (result.ok && result.result.isAccepted) {
console.log('Confirmed!');
return;
}
}

throw new Error('Confirmation timeout');
}
}

interface Payment {
address: string;
amount: string;
metadata?: any;
}

interface BatchResult {
successful: Array<Payment & { txId: string; timestamp: Date }>;
failed: Array<Payment & { error: string; timestamp: Date }>;
totalFees: bigint;
}

// Usage
const processor = new BatchPaymentProcessor(client, wallet);

const payments: Payment[] = [
{ address: 'hoosat:qz95mwas...', amount: HoosatUtils.amountToSompi('1.0') },
{ address: 'hoosat:qzk8h2q7...', amount: HoosatUtils.amountToSompi('0.5') },
{ address: 'hoosat:qpm4n7r8...', amount: HoosatUtils.amountToSompi('0.25') },
{ address: 'hoosat:qab3cd4e...', amount: HoosatUtils.amountToSompi('0.1') },
{ address: 'hoosat:qef5gh6i...', amount: HoosatUtils.amountToSompi('2.0') }
];

const result = await processor.sendBatch(payments);

// Check results
for (const payment of result.successful) {
console.log(`✓ Sent ${HoosatUtils.sompiToAmount(payment.amount)} HTN to ${payment.address}`);
console.log(` TX: ${payment.txId}`);
}

for (const payment of result.failed) {
console.error(`✗ Failed to send to ${payment.address}: ${payment.error}`);
}

Payment Queue System​

Persistent Queue with Retry​

interface QueuedPayment extends Payment {
id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
attempts: number;
lastAttempt?: Date;
txId?: string;
error?: string;
}

class PaymentQueue {
private queue: QueuedPayment[] = [];
private processor: BatchPaymentProcessor;
private maxAttempts: number = 3;
private processing: boolean = false;

constructor(processor: BatchPaymentProcessor) {
this.processor = processor;
}

addPayment(address: string, amount: string, metadata?: any): string {
const id = this.generateId();

const payment: QueuedPayment = {
id,
address,
amount,
metadata,
status: 'pending',
attempts: 0
};

this.queue.push(payment);
console.log(`Payment queued: ${id}`);

return id;
}

addPayments(payments: Payment[]): string[] {
return payments.map(p => this.addPayment(p.address, p.amount, p.metadata));
}

async processQueue(): Promise<void> {
if (this.processing) {
console.log('Queue is already being processed');
return;
}

this.processing = true;
console.log(`Processing queue (${this.getPendingCount()} pending)...`);

while (this.getPendingCount() > 0) {
// Get pending payments
const pending = this.queue.filter(p => p.status === 'pending');
const batch = pending.slice(0, 10); // Process up to 10 payments

if (batch.length === 0) break;

// Mark as processing
for (const payment of batch) {
payment.status = 'processing';
payment.attempts++;
payment.lastAttempt = new Date();
}

// Process batch
try {
const result = await this.processor.sendBatch(
batch.map(p => ({ address: p.address, amount: p.amount, metadata: p.metadata }))
);

// Update successful payments
for (const success of result.successful) {
const payment = batch.find(p => p.address === success.address);
if (payment) {
payment.status = 'completed';
payment.txId = success.txId;
}
}

// Update failed payments
for (const failure of result.failed) {
const payment = batch.find(p => p.address === failure.address);
if (payment) {
if (payment.attempts >= this.maxAttempts) {
payment.status = 'failed';
payment.error = failure.error;
} else {
payment.status = 'pending'; // Retry
}
}
}

} catch (error) {
console.error('Batch processing error:', error);

// Mark all as pending for retry
for (const payment of batch) {
if (payment.attempts >= this.maxAttempts) {
payment.status = 'failed';
payment.error = error.message;
} else {
payment.status = 'pending';
}
}
}

// Wait before next batch
await new Promise(resolve => setTimeout(resolve, 5000));
}

this.processing = false;
console.log('Queue processing complete');
}

getStatus(id: string): QueuedPayment | undefined {
return this.queue.find(p => p.id === id);
}

getPendingCount(): number {
return this.queue.filter(p => p.status === 'pending').length;
}

getCompletedCount(): number {
return this.queue.filter(p => p.status === 'completed').length;
}

getFailedCount(): number {
return this.queue.filter(p => p.status === 'failed').length;
}

printSummary(): void {
console.log('\n=== Queue Summary ===');
console.log(`Total: ${this.queue.length}`);
console.log(`Pending: ${this.getPendingCount()}`);
console.log(`Completed: ${this.getCompletedCount()}`);
console.log(`Failed: ${this.getFailedCount()}`);
console.log('====================\n');
}

private generateId(): string {
return `PAY-${Date.now()}-${Math.random().toString(36).substring(7)}`;
}

exportResults(): QueuedPayment[] {
return [...this.queue];
}
}

// Usage
const queue = new PaymentQueue(processor);

// Add individual payments
queue.addPayment('hoosat:qz95mwas...', HoosatUtils.amountToSompi('1.0'));
queue.addPayment('hoosat:qzk8h2q7...', HoosatUtils.amountToSompi('0.5'));
queue.addPayment('hoosat:qpm4n7r8...', HoosatUtils.amountToSompi('0.25'));

// Or add multiple
const payments: Payment[] = [
{ address: 'hoosat:qab3cd4e...', amount: HoosatUtils.amountToSompi('0.1') },
{ address: 'hoosat:qef5gh6i...', amount: HoosatUtils.amountToSompi('2.0') }
];
queue.addPayments(payments);

// Process
await queue.processQueue();

// Check results
queue.printSummary();

// Export for logging/reporting
const results = queue.exportResults();
console.log(JSON.stringify(results, null, 2));

CSV Batch Payments​

Process Payments from CSV​

import * as fs from 'fs';
import * as csv from 'csv-parser';

interface CSVPayment {
address: string;
amount_htn: string;
description?: string;
}

async function processCSVPayments(csvFilePath: string): Promise<void> {
const payments: Payment[] = [];

// Read CSV
await new Promise((resolve, reject) => {
fs.createReadStream(csvFilePath)
.pipe(csv())
.on('data', (row: CSVPayment) => {
// Validate address
if (!HoosatUtils.isValidAddress(row.address)) {
console.warn(`Skipping invalid address: ${row.address}`);
return;
}

// Validate amount
if (!HoosatUtils.isValidAmount(row.amount_htn)) {
console.warn(`Skipping invalid amount for ${row.address}: ${row.amount_htn}`);
return;
}

// Convert HTN to sompi
const amount = HoosatUtils.amountToSompi(row.amount_htn);

payments.push({
address: row.address,
amount,
metadata: { description: row.description }
});
})
.on('end', resolve)
.on('error', reject);
});

console.log(`Loaded ${payments.length} payments from CSV`);

// Calculate total
const total = payments.reduce((sum, p) => sum + BigInt(p.amount), 0n);
console.log(`Total amount: ${HoosatUtils.sompiToAmount(total)} HTN`);

// Confirm with user
console.log('\nPayments to be sent:');
for (let i = 0; i < Math.min(5, payments.length); i++) {
console.log(` ${payments[i].address}: ${HoosatUtils.sompiToAmount(payments[i].amount)} HTN`);
}
if (payments.length > 5) {
console.log(` ... and ${payments.length - 5} more`);
}

// Process
const processor = new BatchPaymentProcessor(client, wallet);
const result = await processor.sendBatch(payments);

// Generate report
const reportPath = `${csvFilePath}.report.json`;
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2));
console.log(`Report saved to: ${reportPath}`);
}

// Usage
await processCSVPayments('./payments.csv');

Example CSV format:

address,amount_htn,description
hoosat:qz95mwas8ja7ucsernv9z335rdxxqswff7wvzenl29qukn5qs3lsqfsa4pd74,1.0,Alice payment
hoosat:qzk8h2q7wn9p3j5m6x4r8t5v3w9y2k4m7p8q6r9t3v5w8x2z4b7c9d,0.5,Bob payment
hoosat:qpm4n7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2g3h4i5j6k7l8m9n0,0.25,Charlie payment

Exchange Withdrawal System​

Multi-user Withdrawal Batching​

class WithdrawalProcessor {
private client: HoosatClient;
private hotWallet: KeyPair;
private pendingWithdrawals: Map<string, Withdrawal[]> = new Map();
private batchInterval: number = 60000; // 1 minute
private minBatchSize: number = 2;

constructor(client: HoosatClient, hotWallet: KeyPair) {
this.client = client;
this.hotWallet = hotWallet;
}

async requestWithdrawal(
userId: string,
address: string,
amount: string
): Promise<string> {
// Validate
if (!HoosatUtils.isValidAddress(address)) {
throw new Error('Invalid address');
}

if (!HoosatUtils.isValidAmount(amount)) {
throw new Error('Invalid amount');
}

// Create withdrawal
const withdrawal: Withdrawal = {
id: this.generateWithdrawalId(),
userId,
address,
amount,
status: 'pending',
requestedAt: new Date()
};

// Add to queue
if (!this.pendingWithdrawals.has(userId)) {
this.pendingWithdrawals.set(userId, []);
}
this.pendingWithdrawals.get(userId)!.push(withdrawal);

console.log(`Withdrawal requested: ${withdrawal.id}`);
console.log(`Amount: ${HoosatUtils.sompiToAmount(amount)} HTN`);
console.log(`Address: ${address}`);

return withdrawal.id;
}

async processPendingWithdrawals(): Promise<void> {
const allWithdrawals = Array.from(this.pendingWithdrawals.values()).flat();
const pending = allWithdrawals.filter(w => w.status === 'pending');

if (pending.length < this.minBatchSize) {
console.log(`Only ${pending.length} pending withdrawals - waiting for more`);
return;
}

console.log(`Processing ${pending.length} withdrawals...`);

// Group by 2 (max recipients per tx)
const processor = new BatchPaymentProcessor(this.client, this.hotWallet);

const payments: Payment[] = pending.map(w => ({
address: w.address,
amount: w.amount,
metadata: { withdrawalId: w.id, userId: w.userId }
}));

try {
const result = await processor.sendBatch(payments);

// Update successful withdrawals
for (const success of result.successful) {
const withdrawal = pending.find(
w => w.id === success.metadata.withdrawalId
);

if (withdrawal) {
withdrawal.status = 'completed';
withdrawal.txId = success.txId;
withdrawal.completedAt = new Date();

// Notify user
await this.notifyUser(withdrawal.userId, {
type: 'withdrawal_complete',
withdrawalId: withdrawal.id,
txId: success.txId
});
}
}

// Update failed withdrawals
for (const failure of result.failed) {
const withdrawal = pending.find(
w => w.id === failure.metadata.withdrawalId
);

if (withdrawal) {
withdrawal.status = 'failed';
withdrawal.error = failure.error;

// Notify user
await this.notifyUser(withdrawal.userId, {
type: 'withdrawal_failed',
withdrawalId: withdrawal.id,
error: failure.error
});
}
}

} catch (error) {
console.error('Batch processing failed:', error);

// Mark all as failed
for (const withdrawal of pending) {
withdrawal.status = 'failed';
withdrawal.error = error.message;
}
}
}

startAutomaticProcessing(): void {
console.log(`Starting automatic withdrawal processing (every ${this.batchInterval}ms)`);

setInterval(async () => {
try {
await this.processPendingWithdrawals();
} catch (error) {
console.error('Auto-processing error:', error);
}
}, this.batchInterval);
}

private generateWithdrawalId(): string {
return `WD-${Date.now()}-${Math.random().toString(36).substring(7)}`;
}

private async notifyUser(userId: string, notification: any): Promise<void> {
// Implement your notification system
console.log(`[Notify ${userId}]`, notification);
}
}

interface Withdrawal {
id: string;
userId: string;
address: string;
amount: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
requestedAt: Date;
completedAt?: Date;
txId?: string;
error?: string;
}

// Usage
const withdrawalProcessor = new WithdrawalProcessor(client, hotWallet);

// Start automatic processing
withdrawalProcessor.startAutomaticProcessing();

// Users request withdrawals
await withdrawalProcessor.requestWithdrawal(
'user123',
'hoosat:qz95mwas...',
HoosatUtils.amountToSompi('1.0')
);

await withdrawalProcessor.requestWithdrawal(
'user456',
'hoosat:qzk8h2q7...',
HoosatUtils.amountToSompi('0.5')
);

// Processor will automatically batch and send every minute

Best Practices​

1. Validate Before Batching​

function validatePayments(payments: Payment[]): Payment[] {
return payments.filter(p => {
if (!HoosatUtils.isValidAddress(p.address)) {
console.warn(`Invalid address: ${p.address}`);
return false;
}

if (!HoosatUtils.isValidAmount(p.amount)) {
console.warn(`Invalid amount: ${p.amount}`);
return false;
}

return true;
});
}

const validPayments = validatePayments(payments);
console.log(`${validPayments.length}/${payments.length} payments are valid`);

2. Check Balance Before Starting​

const totalNeeded = payments.reduce(
(sum, p) => sum + BigInt(p.amount),
0n
);

const estimatedTotalFees = BigInt(payments.length / 2) * 100000n; // Rough estimate
const balanceResult = await client.getBalance(wallet.address);

if (balanceResult.ok) {
const balance = BigInt(balanceResult.result.balance);

if (balance < totalNeeded + estimatedTotalFees) {
throw new Error(
`Insufficient balance. Need ${HoosatUtils.sompiToAmount(totalNeeded + estimatedTotalFees)} HTN, ` +
`have ${HoosatUtils.sompiToAmount(balance)} HTN`
);
}
}

3. Implement Retry Logic​

async function sendBatchWithRetry(
payments: Payment[],
maxRetries: number = 3
): Promise<string[]> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await processor.sendBatch(payments);
} catch (error) {
console.error(`Attempt ${attempt} failed:`, error.message);

if (attempt === maxRetries) {
throw error;
}

console.log('Retrying...');
await new Promise(resolve => setTimeout(resolve, 5000 * attempt));
}
}

throw new Error('Max retries exceeded');
}

4. Log Everything​

const logFile = `batch-${Date.now()}.log`;

function log(message: string): void {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${message}\n`;

console.log(message);
fs.appendFileSync(logFile, line);
}

log('Starting batch payment process');
log(`Processing ${payments.length} payments`);
log(`Total amount: ${HoosatUtils.sompiToAmount(total)} HTN`);

5. Monitor and Alert​

async function sendBatchWithMonitoring(payments: Payment[]): Promise<void> {
const startTime = Date.now();

try {
const result = await processor.sendBatch(payments);

const duration = (Date.now() - startTime) / 1000;

// Success metrics
console.log(`Batch completed in ${duration}s`);
console.log(`Success rate: ${result.successful.length}/${payments.length}`);

// Alert if low success rate
if (result.failed.length > payments.length * 0.1) {
await sendAlert(`High failure rate: ${result.failed.length}/${payments.length} failed`);
}

} catch (error) {
await sendAlert(`Batch processing failed: ${error.message}`);
throw error;
}
}

async function sendAlert(message: string): Promise<void> {
// Implement your alerting system (email, SMS, Slack, etc.)
console.error('[ALERT]', message);
}

Next Steps​