Web3 Integration Guide Part 4: Sending Cryptocurrency Payments
Introduction
Sending cryptocurrency payments efficiently and securely is crucial for applications that need to pay users, process refunds, handle withdrawals, or distribute rewards. Unlike traditional payment systems, blockchain transactions require careful gas management, queue handling, and error recovery strategies.
In this guide, you'll learn how to:
- Send ETH and ERC-20 tokens to users
- Batch multiple payments into single transactions
- Optimize gas costs and transaction speed
- Build a withdrawal request system
- Implement payment queues with retry logic
- Handle failed transactions and edge cases
- Monitor and track outgoing payments
Understanding Payment Scenarios
Common Use Cases
- Withdrawals: Users request to withdraw earnings/balance
- Refunds: Return payments for cancelled orders
- Rewards/Airdrops: Distribute tokens to many addresses
- Payroll: Pay freelancers/contractors in crypto
- Royalties: Automatic payments to content creators
Payment Architecture
Request → Validation → Queue → Batch → Execute → Confirm → Complete
| | | | | | |
User Check Store Group Send Monitor Update
Action Balance DB Txs Tx Status Records
Building the Withdrawal System
Create src/composables/useWithdrawals.js
import { ref, computed } from 'vue'
import { BrowserProvider, Contract, parseUnits, formatUnits } from 'ethers'
import { useWallet } from './useWallet'
const ERC20_ABI = [
'function transfer(address to, uint256 amount) returns (bool)',
'function balanceOf(address owner) view returns (uint256)',
'function decimals() view returns (uint8)',
'event Transfer(address indexed from, address indexed to, uint256 value)'
]
export function useWithdrawals() {
const { provider, account } = useWallet()
// Withdrawal state
const withdrawals = ref([])
const pendingWithdrawal = ref(null)
const processing = ref(false)
const error = ref(null)
/**
* Create withdrawal request
*/
const createWithdrawal = async ({
recipientAddress,
amount,
currency = 'ETH',
tokenAddress = null,
memo = ''
}) => {
try {
processing.value = true
error.value = null
// Validate recipient address
if (!recipientAddress || !/^0x[a-fA-F0-9]{40}$/.test(recipientAddress)) {
throw new Error('Invalid recipient address')
}
// Validate amount
if (!amount || parseFloat(amount) <= 0) {
throw new Error('Invalid amount')
}
// Check if sender has sufficient balance
const balance = await getBalance(currency, tokenAddress)
if (parseFloat(balance) < parseFloat(amount)) {
throw new Error(`Insufficient ${currency} balance`)
}
const withdrawal = {
id: generateWithdrawalId(),
recipientAddress,
amount: amount.toString(),
currency,
tokenAddress,
memo,
status: 'pending',
createdAt: Date.now(),
txHash: null,
gasUsed: null,
error: null
}
withdrawals.value.push(withdrawal)
pendingWithdrawal.value = withdrawal
console.log('✅ Withdrawal request created:', withdrawal.id)
return withdrawal
} catch (err) {
error.value = err.message
throw err
} finally {
processing.value = false
}
}
/**
* Execute withdrawal (send payment)
*/
const executeWithdrawal = async (withdrawalId) => {
try {
processing.value = true
error.value = null
const withdrawal = withdrawals.value.find(w => w.id === withdrawalId)
if (!withdrawal) {
throw new Error('Withdrawal not found')
}
if (withdrawal.status !== 'pending') {
throw new Error('Withdrawal already processed')
}
withdrawal.status = 'processing'
console.log(`💸 Executing withdrawal ${withdrawalId}`)
let tx
if (withdrawal.currency === 'ETH') {
tx = await sendETH(withdrawal.recipientAddress, withdrawal.amount)
} else {
tx = await sendToken(
withdrawal.tokenAddress,
withdrawal.recipientAddress,
withdrawal.amount
)
}
withdrawal.txHash = tx.hash
withdrawal.status = 'confirming'
console.log(`Transaction sent: ${tx.hash}`)
// Wait for confirmation
const receipt = await tx.wait()
withdrawal.status = 'completed'
withdrawal.completedAt = Date.now()
withdrawal.gasUsed = receipt.gasUsed.toString()
console.log(`✅ Withdrawal ${withdrawalId} completed`)
return withdrawal
} catch (err) {
const withdrawal = withdrawals.value.find(w => w.id === withdrawalId)
if (withdrawal) {
withdrawal.status = 'failed'
withdrawal.error = err.message
}
error.value = err.message
throw err
} finally {
processing.value = false
}
}
/**
* Send ETH to address
*/
const sendETH = async (recipientAddress, amountInEth) => {
if (!provider.value) {
throw new Error('Wallet not connected')
}
const signer = await provider.value.getSigner()
const amountInWei = parseUnits(amountInEth.toString(), 18)
// Estimate gas
const gasEstimate = await signer.estimateGas({
to: recipientAddress,
value: amountInWei
})
// Add 10% buffer to gas estimate
const gasLimit = (gasEstimate * 110n) / 100n
// Get current gas price
const feeData = await provider.value.getFeeData()
const tx = await signer.sendTransaction({
to: recipientAddress,
value: amountInWei,
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas
})
return tx
}
/**
* Send ERC-20 tokens to address
*/
const sendToken = async (tokenAddress, recipientAddress, amount) => {
if (!provider.value) {
throw new Error('Wallet not connected')
}
const signer = await provider.value.getSigner()
const contract = new Contract(tokenAddress, ERC20_ABI, signer)
// Get token decimals
const decimals = await contract.decimals()
const amountInWei = parseUnits(amount.toString(), decimals)
// Estimate gas
const gasEstimate = await contract.transfer.estimateGas(
recipientAddress,
amountInWei
)
// Add 10% buffer
const gasLimit = (gasEstimate * 110n) / 100n
const tx = await contract.transfer(recipientAddress, amountInWei, {
gasLimit
})
return tx
}
/**
* Get balance for currency
*/
const getBalance = async (currency, tokenAddress = null) => {
if (!provider.value || !account.value) {
throw new Error('Wallet not connected')
}
if (currency === 'ETH') {
const balance = await provider.value.getBalance(account.value)
return formatUnits(balance, 18)
} else {
const contract = new Contract(tokenAddress, ERC20_ABI, provider.value)
const decimals = await contract.decimals()
const balance = await contract.balanceOf(account.value)
return formatUnits(balance, decimals)
}
}
/**
* Cancel pending withdrawal
*/
const cancelWithdrawal = (withdrawalId) => {
const withdrawal = withdrawals.value.find(w => w.id === withdrawalId)
if (!withdrawal) {
throw new Error('Withdrawal not found')
}
if (withdrawal.status !== 'pending') {
throw new Error('Cannot cancel withdrawal that is already processing')
}
withdrawal.status = 'cancelled'
withdrawal.cancelledAt = Date.now()
console.log(`❌ Withdrawal ${withdrawalId} cancelled`)
}
/**
* Generate unique withdrawal ID
*/
const generateWithdrawalId = () => {
return `WD-${Date.now()}-${Math.random().toString(36).substr(2, 9).toUpperCase()}`
}
/**
* Get withdrawals by status
*/
const getWithdrawalsByStatus = (status) => {
return withdrawals.value.filter(w => w.status === status)
}
// Computed properties
const pendingWithdrawals = computed(() =>
withdrawals.value.filter(w => w.status === 'pending')
)
const completedWithdrawals = computed(() =>
withdrawals.value.filter(w => w.status === 'completed')
)
const failedWithdrawals = computed(() =>
withdrawals.value.filter(w => w.status === 'failed')
)
return {
// State
withdrawals,
pendingWithdrawal,
processing,
error,
// Computed
pendingWithdrawals,
completedWithdrawals,
failedWithdrawals,
// Methods
createWithdrawal,
executeWithdrawal,
cancelWithdrawal,
getWithdrawalsByStatus,
getBalance
}
}
Batch Payments for Gas Efficiency
Sending many individual transactions is expensive. Instead, use batch transfers to save gas.
Batch Transfer Smart Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract BatchTransfer is Ownable {
/**
* Batch send ETH to multiple addresses
*/
function batchSendETH(
address[] calldata recipients,
uint256[] calldata amounts
) external payable onlyOwner {
require(recipients.length == amounts.length, "Arrays length mismatch");
require(recipients.length > 0, "Empty recipients");
uint256 totalAmount = 0;
for (uint256 i = 0; i < amounts.length; i++) {
totalAmount += amounts[i];
}
require(msg.value >= totalAmount, "Insufficient ETH sent");
for (uint256 i = 0; i < recipients.length; i++) {
(bool success, ) = recipients[i].call{value: amounts[i]}("");
require(success, "Transfer failed");
}
// Refund excess ETH
if (msg.value > totalAmount) {
(bool refundSuccess, ) = msg.sender.call{value: msg.value - totalAmount}("");
require(refundSuccess, "Refund failed");
}
}
/**
* Batch send ERC-20 tokens to multiple addresses
*/
function batchSendToken(
address tokenAddress,
address[] calldata recipients,
uint256[] calldata amounts
) external onlyOwner {
require(recipients.length == amounts.length, "Arrays length mismatch");
require(recipients.length > 0, "Empty recipients");
IERC20 token = IERC20(tokenAddress);
for (uint256 i = 0; i < recipients.length; i++) {
require(
token.transferFrom(msg.sender, recipients[i], amounts[i]),
"Transfer failed"
);
}
}
/**
* Batch send same amount to multiple addresses (airdrops)
*/
function batchSendTokenFixedAmount(
address tokenAddress,
address[] calldata recipients,
uint256 amountEach
) external onlyOwner {
require(recipients.length > 0, "Empty recipients");
IERC20 token = IERC20(tokenAddress);
for (uint256 i = 0; i < recipients.length; i++) {
require(
token.transferFrom(msg.sender, recipients[i], amountEach),
"Transfer failed"
);
}
}
}
Create src/composables/useBatchPayments.js
import { ref } from 'vue'
import { Contract, parseUnits } from 'ethers'
import { useWallet } from './useWallet'
// Replace with your deployed BatchTransfer contract address
const BATCH_CONTRACT_ADDRESS = '0x...'
const BATCH_TRANSFER_ABI = [
'function batchSendETH(address[] recipients, uint256[] amounts) payable',
'function batchSendToken(address tokenAddress, address[] recipients, uint256[] amounts)',
'function batchSendTokenFixedAmount(address tokenAddress, address[] recipients, uint256 amountEach)'
]
const ERC20_ABI = [
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)'
]
export function useBatchPayments() {
const { provider, account } = useWallet()
const processing = ref(false)
const error = ref(null)
const txHash = ref(null)
/**
* Batch send ETH to multiple recipients
*/
const batchSendETH = async (payments) => {
try {
processing.value = true
error.value = null
txHash.value = null
if (!provider.value) {
throw new Error('Wallet not connected')
}
// Validate payments array
if (!Array.isArray(payments) || payments.length === 0) {
throw new Error('Invalid payments array')
}
// Extract recipients and amounts
const recipients = payments.map(p => p.recipient)
const amounts = payments.map(p => parseUnits(p.amount.toString(), 18))
// Calculate total amount needed
const totalAmount = amounts.reduce((sum, amt) => sum + amt, 0n)
const signer = await provider.value.getSigner()
const contract = new Contract(BATCH_CONTRACT_ADDRESS, BATCH_TRANSFER_ABI, signer)
console.log(`💸 Sending batch payment to ${recipients.length} recipients`)
console.log(`Total amount: ${formatUnits(totalAmount, 18)} ETH`)
// Send batch transaction
const tx = await contract.batchSendETH(recipients, amounts, {
value: totalAmount
})
txHash.value = tx.hash
console.log(`Transaction sent: ${tx.hash}`)
const receipt = await tx.wait()
console.log(`✅ Batch payment completed!`)
console.log(`Gas used: ${receipt.gasUsed.toString()}`)
return {
success: true,
txHash: tx.hash,
gasUsed: receipt.gasUsed.toString(),
recipients: recipients.length
}
} catch (err) {
error.value = err.message
console.error('Batch payment error:', err)
throw err
} finally {
processing.value = false
}
}
/**
* Batch send ERC-20 tokens to multiple recipients
*/
const batchSendToken = async (tokenAddress, payments) => {
try {
processing.value = true
error.value = null
txHash.value = null
if (!provider.value) {
throw new Error('Wallet not connected')
}
const signer = await provider.value.getSigner()
const batchContract = new Contract(BATCH_CONTRACT_ADDRESS, BATCH_TRANSFER_ABI, signer)
const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer)
// Get token decimals
const decimals = await tokenContract.decimals()
// Extract recipients and amounts
const recipients = payments.map(p => p.recipient)
const amounts = payments.map(p => parseUnits(p.amount.toString(), decimals))
// Calculate total amount
const totalAmount = amounts.reduce((sum, amt) => sum + amt, 0n)
// Check allowance
const allowance = await tokenContract.allowance(account.value, BATCH_CONTRACT_ADDRESS)
if (allowance < totalAmount) {
console.log('Approving batch contract...')
const approveTx = await tokenContract.approve(BATCH_CONTRACT_ADDRESS, totalAmount)
await approveTx.wait()
console.log('✅ Approval confirmed')
}
console.log(`💸 Sending batch token payment to ${recipients.length} recipients`)
// Send batch transaction
const tx = await batchContract.batchSendToken(tokenAddress, recipients, amounts)
txHash.value = tx.hash
console.log(`Transaction sent: ${tx.hash}`)
const receipt = await tx.wait()
console.log(`✅ Batch token payment completed!`)
return {
success: true,
txHash: tx.hash,
gasUsed: receipt.gasUsed.toString(),
recipients: recipients.length
}
} catch (err) {
error.value = err.message
console.error('Batch token payment error:', err)
throw err
} finally {
processing.value = false
}
}
/**
* Calculate gas savings from batching
*/
const calculateBatchSavings = async (numRecipients, currency = 'ETH') => {
if (!provider.value) return null
// Approximate gas costs
const singleTxGas = currency === 'ETH' ? 21000 : 65000 // ETH transfer vs ERC-20
const batchTxBaseGas = 50000
const batchTxPerRecipientGas = currency === 'ETH' ? 15000 : 40000
const individualGasCost = singleTxGas * numRecipients
const batchGasCost = batchTxBaseGas + (batchTxPerRecipientGas * numRecipients)
const savings = individualGasCost - batchGasCost
const savingsPercent = ((savings / individualGasCost) * 100).toFixed(1)
// Get current gas price
const feeData = await provider.value.getFeeData()
const gasPrice = feeData.gasPrice
const savingsInEth = Number(savings * gasPrice) / 1e18
return {
individualGas: individualGasCost,
batchGas: batchGasCost,
savedGas: savings,
savedPercent: savingsPercent,
savedEth: savingsInEth.toFixed(6),
recommended: savings > 0
}
}
return {
processing,
error,
txHash,
batchSendETH,
batchSendToken,
calculateBatchSavings
}
}
Payment Queue System
For high-volume applications, implement a queue to manage payments efficiently.
Create src/composables/usePaymentQueue.js
import { ref, computed } from 'vue'
import { useWithdrawals } from './useWithdrawals'
import { useBatchPayments } from './useBatchPayments'
export function usePaymentQueue() {
const { executeWithdrawal } = useWithdrawals()
const { batchSendETH, batchSendToken } = useBatchPayments()
const queue = ref([])
const processing = ref(false)
const processedCount = ref(0)
const failedCount = ref(0)
// Queue configuration
const config = ref({
batchSize: 50, // Max recipients per batch
delayBetweenBatches: 5000, // 5 seconds
maxRetries: 3,
processingMode: 'batch' // 'individual' or 'batch'
})
/**
* Add payment to queue
*/
const addToQueue = (payment) => {
const queueItem = {
...payment,
id: `Q-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
status: 'queued',
retries: 0,
addedAt: Date.now(),
processedAt: null,
error: null
}
queue.value.push(queueItem)
console.log(`➕ Added to queue: ${queueItem.id}`)
return queueItem
}
/**
* Add multiple payments to queue
*/
const addBulkToQueue = (payments) => {
const queueItems = payments.map(p => addToQueue(p))
console.log(`➕ Added ${queueItems.length} payments to queue`)
return queueItems
}
/**
* Process queue
*/
const processQueue = async () => {
if (processing.value) {
console.log('⚠️ Queue already processing')
return
}
processing.value = true
processedCount.value = 0
failedCount.value = 0
console.log(`🚀 Processing queue (${pendingItems.value.length} items)`)
try {
if (config.value.processingMode === 'batch') {
await processBatched()
} else {
await processIndividual()
}
console.log(`✅ Queue processing complete`)
console.log(`Processed: ${processedCount.value}, Failed: ${failedCount.value}`)
} catch (err) {
console.error('Queue processing error:', err)
} finally {
processing.value = false
}
}
/**
* Process queue in batches
*/
const processBatched = async () => {
const pending = pendingItems.value
// Group by currency
const ethPayments = pending.filter(p => p.currency === 'ETH')
const tokenGroups = new Map()
pending
.filter(p => p.currency !== 'ETH')
.forEach(p => {
if (!tokenGroups.has(p.tokenAddress)) {
tokenGroups.set(p.tokenAddress, [])
}
tokenGroups.get(p.tokenAddress).push(p)
})
// Process ETH batches
if (ethPayments.length > 0) {
await processCurrencyBatches(ethPayments, 'ETH')
}
// Process token batches
for (const [tokenAddress, payments] of tokenGroups) {
await processCurrencyBatches(payments, 'TOKEN', tokenAddress)
// Delay between different token batches
if (tokenGroups.size > 1) {
await sleep(config.value.delayBetweenBatches)
}
}
}
/**
* Process batches for a specific currency
*/
const processCurrencyBatches = async (payments, type, tokenAddress = null) => {
const batchSize = config.value.batchSize
const batches = []
// Split into batches
for (let i = 0; i < payments.length; i += batchSize) {
batches.push(payments.slice(i, i + batchSize))
}
console.log(`Processing ${batches.length} batches for ${type}`)
// Process each batch
for (let i = 0; i < batches.length; i++) {
const batch = batches[i]
console.log(`Processing batch ${i + 1}/${batches.length} (${batch.length} payments)`)
try {
// Mark as processing
batch.forEach(item => {
item.status = 'processing'
})
// Prepare payment data
const paymentData = batch.map(item => ({
recipient: item.recipientAddress,
amount: item.amount
}))
// Execute batch
let result
if (type === 'ETH') {
result = await batchSendETH(paymentData)
} else {
result = await batchSendToken(tokenAddress, paymentData)
}
// Mark as completed
batch.forEach(item => {
item.status = 'completed'
item.processedAt = Date.now()
item.txHash = result.txHash
processedCount.value++
})
console.log(`✅ Batch ${i + 1} completed: ${result.txHash}`)
} catch (err) {
console.error(`❌ Batch ${i + 1} failed:`, err.message)
// Mark as failed or retry
batch.forEach(item => {
if (item.retries < config.value.maxRetries) {
item.status = 'queued'
item.retries++
console.log(`🔄 Retrying ${item.id} (attempt ${item.retries})`)
} else {
item.status = 'failed'
item.error = err.message
failedCount.value++
}
})
}
// Delay between batches
if (i < batches.length - 1) {
await sleep(config.value.delayBetweenBatches)
}
}
}
/**
* Process queue individually
*/
const processIndividual = async () => {
const pending = pendingItems.value
for (const item of pending) {
try {
item.status = 'processing'
await executeWithdrawal(item.withdrawalId)
item.status = 'completed'
item.processedAt = Date.now()
processedCount.value++
console.log(`✅ Processed: ${item.id}`)
} catch (err) {
console.error(`❌ Failed: ${item.id}`, err.message)
if (item.retries < config.value.maxRetries) {
item.status = 'queued'
item.retries++
} else {
item.status = 'failed'
item.error = err.message
failedCount.value++
}
}
// Small delay between individual transactions
await sleep(2000)
}
}
/**
* Retry failed items
*/
const retryFailed = async () => {
const failed = failedItems.value
failed.forEach(item => {
item.status = 'queued'
item.retries = 0
item.error = null
})
console.log(`🔄 Retrying ${failed.length} failed items`)
await processQueue()
}
/**
* Clear completed items from queue
*/
const clearCompleted = () => {
const completedCount = completedItems.value.length
queue.value = queue.value.filter(item => item.status !== 'completed')
console.log(`🗑️ Cleared ${completedCount} completed items`)
}
/**
* Clear all items from queue
*/
const clearAll = () => {
const count = queue.value.length
queue.value = []
console.log(`🗑️ Cleared all ${count} items from queue`)
}
/**
* Helper: Sleep function
*/
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
// Computed properties
const pendingItems = computed(() =>
queue.value.filter(item => item.status === 'queued')
)
const processingItems = computed(() =>
queue.value.filter(item => item.status === 'processing')
)
const completedItems = computed(() =>
queue.value.filter(item => item.status === 'completed')
)
const failedItems = computed(() =>
queue.value.filter(item => item.status === 'failed')
)
const queueStats = computed(() => ({
total: queue.value.length,
pending: pendingItems.value.length,
processing: processingItems.value.length,
completed: completedItems.value.length,
failed: failedItems.value.length
}))
return {
// State
queue,
processing,
config,
processedCount,
failedCount,
// Computed
pendingItems,
processingItems,
completedItems,
failedItems,
queueStats,
// Methods
addToQueue,
addBulkToQueue,
processQueue,
retryFailed,
clearCompleted,
clearAll
}
}
Gas Optimization Strategies
1. Dynamic Gas Pricing
/**
* Get optimal gas price based on urgency
*/
async function getOptimalGasPrice(provider, urgency = 'standard') {
const feeData = await provider.getFeeData()
// Get base fee and priority fee
const baseFee = feeData.maxFeePerGas
const priorityFee = feeData.maxPriorityFeePerGas
// Adjust based on urgency
const multipliers = {
slow: 0.8, // 80% - May take longer
standard: 1.0, // 100% - Normal speed
fast: 1.2, // 120% - Faster confirmation
instant: 1.5 // 150% - Very fast
}
const multiplier = multipliers[urgency] || 1.0
return {
maxFeePerGas: BigInt(Math.floor(Number(baseFee) * multiplier)),
maxPriorityFeePerGas: BigInt(Math.floor(Number(priorityFee) * multiplier))
}
}
// Usage
const { maxFeePerGas, maxPriorityFeePerGas } = await getOptimalGasPrice(provider, 'fast')
const tx = await signer.sendTransaction({
to: recipient,
value: amount,
maxFeePerGas,
maxPriorityFeePerGas
})
2. Gas Price Monitoring
/**
* Wait for favorable gas prices
*/
async function waitForLowGas(provider, targetGwei = 30, timeoutMs = 300000) {
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
const feeData = await provider.getFeeData()
const currentGwei = Number(feeData.gasPrice) / 1e9
console.log(`Current gas price: ${currentGwei.toFixed(2)} gwei`)
if (currentGwei <= targetGwei) {
console.log(`✅ Gas price acceptable!`)
return feeData
}
console.log(`⏳ Waiting for lower gas (target: ${targetGwei} gwei)`)
await new Promise(resolve => setTimeout(resolve, 30000)) // Check every 30s
}
throw new Error('Timeout waiting for low gas prices')
}
// Usage
try {
const feeData = await waitForLowGas(provider, 25) // Wait for 25 gwei or lower
// Proceed with transaction
} catch (err) {
console.log('Using current gas prices instead')
}
3. Transaction Replacement (Speed Up/Cancel)
/**
* Speed up a pending transaction
*/
async function speedUpTransaction(provider, originalTx, increasePercent = 20) {
const signer = await provider.getSigner()
// Get original transaction
const tx = await provider.getTransaction(originalTx.hash)
if (!tx || tx.blockNumber) {
throw new Error('Transaction already mined or not found')
}
// Increase gas price by percentage
const newGasPrice = (tx.gasPrice * BigInt(100 + increasePercent)) / 100n
// Send replacement transaction with same nonce but higher gas
const speedUpTx = await signer.sendTransaction({
to: tx.to,
value: tx.value,
data: tx.data,
nonce: tx.nonce, // Same nonce replaces the transaction
gasLimit: tx.gasLimit,
gasPrice: newGasPrice
})
console.log(`🚀 Speed up tx sent: ${speedUpTx.hash}`)
return speedUpTx
}
/**
* Cancel a pending transaction
*/
async function cancelTransaction(provider, originalTx) {
const signer = await provider.getSigner()
const account = await signer.getAddress()
const tx = await provider.getTransaction(originalTx.hash)
if (!tx || tx.blockNumber) {
throw new Error('Transaction already mined or not found')
}
// Send 0 ETH to yourself with same nonce but higher gas
const cancelTx = await signer.sendTransaction({
to: account,
value: 0,
nonce: tx.nonce,
gasLimit: 21000,
gasPrice: (tx.gasPrice * 120n) / 100n // 20% higher
})
console.log(`❌ Cancel tx sent: ${cancelTx.hash}`)
return cancelTx
}
Building a Withdrawal Dashboard
Create src/components/WithdrawalDashboard.vue
<template>
<div class="withdrawal-dashboard">
<h2>💸 Payment Queue Management</h2>
<!-- Queue Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ queueStats.total }}</div>
<div class="stat-label">Total in Queue</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ queueStats.pending }}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ queueStats.completed }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card danger">
<div class="stat-value">{{ queueStats.failed }}</div>
<div class="stat-label">Failed</div>
</div>
</div>
<!-- Controls -->
<div class="controls">
<button
@click="processQueue"
:disabled="processing || queueStats.pending === 0"
class="btn-primary"
>
{{ processing ? 'Processing...' : 'Process Queue' }}
</button>
<button
@click="retryFailed"
:disabled="processing || queueStats.failed === 0"
class="btn-warning"
>
Retry Failed ({{ queueStats.failed }})
</button>
<button
@click="clearCompleted"
:disabled="queueStats.completed === 0"
class="btn-secondary"
>
Clear Completed
</button>
<button
@click="showAddModal = true"
class="btn-success"
>
+ Add Payment
</button>
</div>
<!-- Processing Progress -->
<div v-if="processing" class="processing-status">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: progressPercent + '%' }"
></div>
</div>
<p>Processing: {{ processedCount }} completed, {{ failedCount }} failed</p>
</div>
<!-- Queue Items -->
<div class="queue-list">
<h3>Queue Items</h3>
<div v-if="queue.length === 0" class="empty-state">
<p>No payments in queue</p>
</div>
<div v-else>
<div
v-for="item in queue"
:key="item.id"
class="queue-item"
:class="'status-' + item.status"
>
<div class="item-header">
<span class="item-id">{{ item.id }}</span>
<span class="item-status" :class="'badge-' + item.status">
{{ item.status }}
</span>
</div>
<div class="item-details">
<div class="detail">
<span class="label">Recipient:</span>
<span class="value">{{ shortenAddress(item.recipientAddress) }}</span>
</div>
<div class="detail">
<span class="label">Amount:</span>
<span class="value">{{ item.amount }} {{ item.currency }}</span>
</div>
<div v-if="item.retries > 0" class="detail">
<span class="label">Retries:</span>
<span class="value">{{ item.retries }}</span>
</div>
</div>
<div v-if="item.txHash" class="item-tx">
<a
:href="`https://sepolia.etherscan.io/tx/${item.txHash}`"
target="_blank"
>
View Transaction →
</a>
</div>
<div v-if="item.error" class="item-error">
⚠️ {{ item.error }}
</div>
</div>
</div>
</div>
<!-- Add Payment Modal -->
<div v-if="showAddModal" class="modal-overlay" @click="showAddModal = false">
<div class="modal" @click.stop>
<h3>Add Payment to Queue</h3>
<label>Recipient Address:</label>
<input
v-model="newPayment.recipientAddress"
placeholder="0x..."
class="input"
/>
<label>Amount:</label>
<input
v-model="newPayment.amount"
type="number"
step="0.01"
placeholder="0.00"
class="input"
/>
<label>Currency:</label>
<select v-model="newPayment.currency" class="input">
<option value="ETH">ETH</option>
<option value="USDC">USDC</option>
<option value="DAI">DAI</option>
</select>
<div class="modal-actions">
<button @click="handleAddPayment" class="btn-primary">
Add to Queue
</button>
<button @click="showAddModal = false" class="btn-secondary">
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePaymentQueue } from '../composables/usePaymentQueue'
const {
queue,
processing,
processedCount,
failedCount,
queueStats,
addToQueue,
processQueue: processQueueFn,
retryFailed: retryFailedFn,
clearCompleted: clearCompletedFn
} = usePaymentQueue()
const showAddModal = ref(false)
const newPayment = ref({
recipientAddress: '',
amount: '',
currency: 'ETH',
tokenAddress: null
})
const progressPercent = computed(() => {
const total = processedCount.value + failedCount.value
const target = queueStats.value.pending + queueStats.value.processing
return target > 0 ? (total / target) * 100 : 0
})
const handleAddPayment = () => {
if (!newPayment.value.recipientAddress || !newPayment.value.amount) {
alert('Please fill all fields')
return
}
addToQueue({
recipientAddress: newPayment.value.recipientAddress,
amount: newPayment.value.amount,
currency: newPayment.value.currency,
tokenAddress: newPayment.value.tokenAddress
})
// Reset form
newPayment.value = {
recipientAddress: '',
amount: '',
currency: 'ETH',
tokenAddress: null
}
showAddModal.value = false
}
const processQueue = () => processQueueFn()
const retryFailed = () => retryFailedFn()
const clearCompleted = () => clearCompletedFn()
const shortenAddress = (address) => {
if (!address) return ''
return `${address.slice(0, 6)}...${address.slice(-4)}`
}
</script>
<style scoped>
.withdrawal-dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h2 {
margin-bottom: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card.success {
background: #d4edda;
}
.stat-card.danger {
background: #f8d7da;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #333;
}
.stat-label {
font-size: 0.9rem;
color: #666;
margin-top: 0.5rem;
}
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.btn-primary, .btn-secondary, .btn-success, .btn-warning {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-warning {
background: #ffc107;
color: #333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.processing-status {
background: #fff3cd;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.progress-bar {
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: #28a745;
transition: width 0.3s;
}
.queue-list {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.queue-item {
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 1rem;
}
.queue-item.status-completed {
border-color: #28a745;
background: #f0f9f4;
}
.queue-item.status-failed {
border-color: #dc3545;
background: #fdf4f5;
}
.queue-item.status-processing {
border-color: #ffc107;
background: #fffbf0;
}
.item-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.item-id {
font-weight: 600;
font-family: monospace;
}
.badge-queued {
background: #e9ecef;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
}
.badge-processing {
background: #fff3cd;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
}
.badge-completed {
background: #d4edda;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
}
.badge-failed {
background: #f8d7da;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
}
.item-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
}
.detail {
display: flex;
gap: 0.5rem;
}
.label {
color: #666;
font-weight: 500;
}
.value {
font-family: monospace;
font-weight: 600;
}
.item-tx {
margin-top: 0.75rem;
}
.item-tx a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.item-error {
margin-top: 0.75rem;
color: #dc3545;
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
padding: 2rem;
border-radius: 12px;
max-width: 500px;
width: 90%;
}
.input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 1rem;
}
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.modal-actions button {
flex: 1;
}
</style>
Production Best Practices
1. Secure Private Key Management
// ❌ Never do this!
const privateKey = '0x1234...' // Hardcoded private key
// ✅ Use environment variables
const privateKey = process.env.PAYMENT_WALLET_PRIVATE_KEY
// ✅ Even better: Use AWS KMS, Google Secret Manager, or HashiCorp Vault
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager')
const client = new SecretManagerServiceClient()
async function getPrivateKey() {
const [version] = await client.accessSecretVersion({
name: 'projects/PROJECT_ID/secrets/payment-wallet-key/versions/latest'
})
return version.payload.data.toString()
}
2. Transaction Monitoring & Alerts
// Send alerts for critical events
async function sendAlert(type, message, data) {
// Slack notification
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🚨 ${type}: ${message}`,
attachments: [{
color: type === 'ERROR' ? 'danger' : 'warning',
fields: Object.entries(data).map(([key, value]) => ({
title: key,
value: String(value),
short: true
}))
}]
})
})
// Email notification
// PagerDuty for critical issues
// etc.
}
// Usage
try {
await batchSendETH(payments)
} catch (err) {
await sendAlert('ERROR', 'Batch payment failed', {
error: err.message,
paymentCount: payments.length,
timestamp: new Date().toISOString()
})
}
3. Wallet Balance Monitoring
async function checkWalletBalance(provider, address, minBalanceEth = 0.1) {
const balance = await provider.getBalance(address)
const balanceEth = Number(balance) / 1e18
if (balanceEth < minBalanceEth) {
await sendAlert('WARNING', 'Low wallet balance', {
address,
balance: balanceEth.toFixed(4),
minimum: minBalanceEth
})
}
return balanceEth
}
// Run periodically
setInterval(async () => {
await checkWalletBalance(provider, PAYMENT_WALLET_ADDRESS, 0.5)
}, 3600000) // Every hour
What's Next?
You now have a complete payment sending system with batching, queues, and optimization! In the final part, we'll cover production deployment and security:
- Part 5: Security auditing, testing strategies, production monitoring, incident response, compliance