Web3 Integration Guide Part 3: Accepting Cryptocurrency Payments
Introduction
Accepting cryptocurrency payments opens your application to global customers without traditional payment processors, chargebacks, or geographic restrictions. In this guide, we'll build a complete payment system that accepts both native ETH and ERC-20 tokens (like USDC, DAI, etc.).
You'll learn how to:
- Create payment invoices with QR codes
- Accept ETH and ERC-20 token payments
- Monitor payment status in real-time
- Handle payment confirmations and webhooks
- Build a complete checkout flow
- Store payment records securely
- Handle edge cases (underpayment, overpayment, refunds)
Payment Architecture Overview
How Crypto Payments Work
- Generate payment address: Create unique address for each order
- Display payment details: Show amount, address, QR code
- Monitor blockchain: Listen for incoming transactions
- Verify payment: Confirm correct amount and token
- Wait for confirmations: Ensure transaction finality
- Fulfill order: Deliver product/service after confirmation
Payment Flow Diagram
Customer Your App Blockchain
| | |
|--[1. Create Order]------->| |
| |--[2. Generate Address]-->|
|<--[3. Show Payment Info]--| |
| | |
|--[4. Send Transaction]------------------->| |
| | |
| |<-[5. Detect Tx]----------|
| | |
| |--[6. Verify Amount]----->|
| | |
| |<-[7. Wait Confirmations]-|
| | |
|<--[8. Order Confirmed]----| |
Building the Payment System
Create src/composables/usePayments.js
import { ref, computed } from 'vue'
import { BrowserProvider, Contract, parseUnits, formatUnits } from 'ethers'
import { useWallet } from './useWallet'
// ERC-20 ABI (minimal - just what we need)
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 usePayments() {
const { provider, account } = useWallet()
// Payment state
const currentPayment = ref(null)
const paymentStatus = ref('idle') // idle, pending, confirming, confirmed, failed
const confirmations = ref(0)
const txHash = ref(null)
const error = ref(null)
// Payment monitoring
let paymentListener = null
let confirmationInterval = null
/**
* Generate payment invoice
*/
const createPaymentInvoice = ({
amount,
currency = 'ETH',
tokenAddress = null,
recipientAddress,
orderId,
description = '',
requiredConfirmations = 3
}) => {
const invoice = {
id: generateInvoiceId(),
orderId,
amount: amount.toString(),
currency,
tokenAddress,
recipientAddress,
description,
requiredConfirmations,
status: 'pending',
createdAt: Date.now(),
expiresAt: Date.now() + (30 * 60 * 1000), // 30 minutes
txHash: null,
confirmations: 0
}
currentPayment.value = invoice
paymentStatus.value = 'pending'
return invoice
}
/**
* Generate unique invoice ID
*/
const generateInvoiceId = () => {
return `INV-${Date.now()}-${Math.random().toString(36).substr(2, 9).toUpperCase()}`
}
/**
* Send ETH payment
*/
const sendETHPayment = async (recipientAddress, amountInEth) => {
try {
paymentStatus.value = 'sending'
error.value = null
if (!provider.value) {
throw new Error('Wallet not connected')
}
const signer = await provider.value.getSigner()
// Convert amount to wei
const amountInWei = parseUnits(amountInEth.toString(), 18)
// Send transaction
const tx = await signer.sendTransaction({
to: recipientAddress,
value: amountInWei
})
txHash.value = tx.hash
console.log('💳 Payment sent:', tx.hash)
// Update invoice
if (currentPayment.value) {
currentPayment.value.txHash = tx.hash
currentPayment.value.status = 'confirming'
}
paymentStatus.value = 'confirming'
// Wait for confirmations
await monitorConfirmations(tx.hash)
return tx
} catch (err) {
error.value = err.message
paymentStatus.value = 'failed'
if (currentPayment.value) {
currentPayment.value.status = 'failed'
currentPayment.value.error = err.message
}
throw err
}
}
/**
* Send ERC-20 token payment
*/
const sendTokenPayment = async (tokenAddress, recipientAddress, amount) => {
try {
paymentStatus.value = 'sending'
error.value = null
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)
console.log(`Sending ${amount} tokens to ${recipientAddress}`)
// Send transfer transaction
const tx = await contract.transfer(recipientAddress, amountInWei)
txHash.value = tx.hash
console.log('💳 Token payment sent:', tx.hash)
// Update invoice
if (currentPayment.value) {
currentPayment.value.txHash = tx.hash
currentPayment.value.status = 'confirming'
}
paymentStatus.value = 'confirming'
// Wait for confirmations
await monitorConfirmations(tx.hash)
return tx
} catch (err) {
error.value = err.message
paymentStatus.value = 'failed'
if (currentPayment.value) {
currentPayment.value.status = 'failed'
currentPayment.value.error = err.message
}
throw err
}
}
/**
* Monitor transaction confirmations
*/
const monitorConfirmations = async (transactionHash) => {
const requiredConfirmations = currentPayment.value?.requiredConfirmations || 3
return new Promise((resolve, reject) => {
let confirmedBlocks = 0
// Check confirmation count every 3 seconds
confirmationInterval = setInterval(async () => {
try {
const receipt = await provider.value.getTransactionReceipt(transactionHash)
if (receipt) {
const currentBlock = await provider.value.getBlockNumber()
confirmedBlocks = currentBlock - receipt.blockNumber
confirmations.value = confirmedBlocks
if (currentPayment.value) {
currentPayment.value.confirmations = confirmedBlocks
}
console.log(`⏳ Confirmations: ${confirmedBlocks}/${requiredConfirmations}`)
// Check if we have enough confirmations
if (confirmedBlocks >= requiredConfirmations) {
clearInterval(confirmationInterval)
paymentStatus.value = 'confirmed'
if (currentPayment.value) {
currentPayment.value.status = 'confirmed'
currentPayment.value.confirmedAt = Date.now()
}
console.log('✅ Payment confirmed!')
resolve(receipt)
}
}
} catch (err) {
clearInterval(confirmationInterval)
paymentStatus.value = 'failed'
error.value = err.message
reject(err)
}
}, 3000) // Check every 3 seconds
})
}
/**
* Listen for incoming payments to an address
*/
const listenForPayment = async (recipientAddress, expectedAmount, currency = 'ETH', tokenAddress = null) => {
try {
if (!provider.value) {
throw new Error('Provider not available')
}
console.log(`👂 Listening for ${currency} payment to ${recipientAddress}`)
if (currency === 'ETH') {
// Listen for ETH transfers
paymentListener = provider.value.on('block', async (blockNumber) => {
const block = await provider.value.getBlock(blockNumber, true)
// Check transactions in the block
for (const tx of block.transactions) {
if (tx.to?.toLowerCase() === recipientAddress.toLowerCase()) {
const amountInEth = formatUnits(tx.value, 18)
console.log(`💰 Received ${amountInEth} ETH in tx ${tx.hash}`)
// Check if amount matches
if (parseFloat(amountInEth) >= parseFloat(expectedAmount)) {
txHash.value = tx.hash
paymentStatus.value = 'confirming'
if (currentPayment.value) {
currentPayment.value.txHash = tx.hash
currentPayment.value.status = 'confirming'
}
// Stop listening and start monitoring confirmations
stopListening()
await monitorConfirmations(tx.hash)
}
}
}
})
} else {
// Listen for ERC-20 token transfers
const contract = new Contract(tokenAddress, ERC20_ABI, provider.value)
const decimals = await contract.decimals()
paymentListener = contract.on('Transfer', async (from, to, amount, event) => {
if (to.toLowerCase() === recipientAddress.toLowerCase()) {
const amountInTokens = formatUnits(amount, decimals)
console.log(`💰 Received ${amountInTokens} ${currency} in tx ${event.log.transactionHash}`)
// Check if amount matches
if (parseFloat(amountInTokens) >= parseFloat(expectedAmount)) {
txHash.value = event.log.transactionHash
paymentStatus.value = 'confirming'
if (currentPayment.value) {
currentPayment.value.txHash = event.log.transactionHash
currentPayment.value.status = 'confirming'
}
// Stop listening and start monitoring confirmations
stopListening()
await monitorConfirmations(event.log.transactionHash)
}
}
})
}
} catch (err) {
console.error('Error listening for payment:', err)
error.value = err.message
throw err
}
}
/**
* Stop listening for payments
*/
const stopListening = () => {
if (paymentListener) {
if (typeof paymentListener === 'function') {
paymentListener() // Call the cleanup function
}
paymentListener = null
}
if (confirmationInterval) {
clearInterval(confirmationInterval)
confirmationInterval = null
}
console.log('👋 Stopped listening for payments')
}
/**
* Verify payment was received
*/
const verifyPayment = async (transactionHash, expectedAmount, currency = 'ETH', tokenAddress = null) => {
try {
if (!provider.value) {
throw new Error('Provider not available')
}
const receipt = await provider.value.getTransactionReceipt(transactionHash)
if (!receipt) {
return {
verified: false,
reason: 'Transaction not found'
}
}
if (receipt.status === 0) {
return {
verified: false,
reason: 'Transaction failed'
}
}
if (currency === 'ETH') {
// Verify ETH amount
const tx = await provider.value.getTransaction(transactionHash)
const amountInEth = formatUnits(tx.value, 18)
const verified = parseFloat(amountInEth) >= parseFloat(expectedAmount)
return {
verified,
amount: amountInEth,
currency: 'ETH',
reason: verified ? 'Payment verified' : 'Amount mismatch'
}
} else {
// Verify ERC-20 token amount from logs
const transferLog = receipt.logs.find(log => {
try {
const contract = new Contract(tokenAddress, ERC20_ABI, provider.value)
const parsedLog = contract.interface.parseLog(log)
return parsedLog?.name === 'Transfer'
} catch {
return false
}
})
if (!transferLog) {
return {
verified: false,
reason: 'No Transfer event found'
}
}
const contract = new Contract(tokenAddress, ERC20_ABI, provider.value)
const decimals = await contract.decimals()
const parsedLog = contract.interface.parseLog(transferLog)
const amountInTokens = formatUnits(parsedLog.args.value, decimals)
const verified = parseFloat(amountInTokens) >= parseFloat(expectedAmount)
return {
verified,
amount: amountInTokens,
currency,
reason: verified ? 'Payment verified' : 'Amount mismatch'
}
}
} catch (err) {
console.error('Error verifying payment:', err)
return {
verified: false,
reason: err.message
}
}
}
/**
* Calculate payment expiry remaining time
*/
const getExpiryTime = computed(() => {
if (!currentPayment.value?.expiresAt) return null
const remaining = currentPayment.value.expiresAt - Date.now()
if (remaining <= 0) return 'Expired'
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
/**
* Check if payment is expired
*/
const isExpired = computed(() => {
if (!currentPayment.value?.expiresAt) return false
return Date.now() > currentPayment.value.expiresAt
})
/**
* Reset payment state
*/
const resetPayment = () => {
stopListening()
currentPayment.value = null
paymentStatus.value = 'idle'
confirmations.value = 0
txHash.value = null
error.value = null
}
return {
// State
currentPayment,
paymentStatus,
confirmations,
txHash,
error,
// Computed
getExpiryTime,
isExpired,
// Methods
createPaymentInvoice,
sendETHPayment,
sendTokenPayment,
listenForPayment,
stopListening,
verifyPayment,
monitorConfirmations,
resetPayment
}
}
Building the Checkout Component
Create src/components/CheckoutFlow.vue
<template>
<div class="checkout-flow">
<div class="checkout-container">
<!-- Step 1: Create Order -->
<div v-if="step === 'create'" class="step">
<h2>🛒 Checkout</h2>
<div class="order-summary">
<h3>Order Summary</h3>
<div class="item">
<span>{{ product.name }}</span>
<span>{{ product.price }} {{ selectedCurrency }}</span>
</div>
<div class="total">
<span>Total:</span>
<span class="amount">{{ product.price }} {{ selectedCurrency }}</span>
</div>
</div>
<div class="payment-method">
<h3>Select Payment Method</h3>
<div class="currency-selector">
<button
v-for="currency in supportedCurrencies"
:key="currency.symbol"
@click="selectedCurrency = currency.symbol"
:class="{ active: selectedCurrency === currency.symbol }"
class="currency-btn"
>
{{ currency.icon }} {{ currency.symbol }}
</button>
</div>
</div>
<button @click="createOrder" class="btn-primary btn-large">
Continue to Payment
</button>
</div>
<!-- Step 2: Payment -->
<div v-else-if="step === 'payment'" class="step">
<h2>💳 Complete Payment</h2>
<div v-if="!isConnected" class="connect-prompt">
<p>Please connect your wallet to continue</p>
<button @click="$emit('connect-wallet')" class="btn-primary">
Connect Wallet
</button>
</div>
<div v-else>
<!-- Payment Details -->
<div class="payment-details">
<div class="detail-row">
<span class="label">Invoice ID:</span>
<span class="value">{{ currentPayment?.id }}</span>
</div>
<div class="detail-row">
<span class="label">Amount:</span>
<span class="value">{{ currentPayment?.amount }} {{ currentPayment?.currency }}</span>
</div>
<div class="detail-row">
<span class="label">Recipient:</span>
<span class="value address">{{ shortenAddress(currentPayment?.recipientAddress) }}</span>
</div>
<div class="detail-row">
<span class="label">Expires in:</span>
<span class="value" :class="{ expired: isExpired }">{{ getExpiryTime }}</span>
</div>
</div>
<!-- QR Code -->
<div class="qr-code-section">
<h4>Scan to Pay (Mobile Wallet)</h4>
<div class="qr-code">
<canvas ref="qrCanvas"></canvas>
</div>
<p class="qr-hint">Scan with your mobile wallet app</p>
</div>
<div class="divider">OR</div>
<!-- Send Payment Button -->
<button
@click="handleSendPayment"
:disabled="paymentStatus !== 'pending' || isExpired"
class="btn-primary btn-large"
>
{{ paymentButtonText }}
</button>
<!-- Status Display -->
<div v-if="paymentStatus !== 'pending' && paymentStatus !== 'idle'" class="payment-status">
<div v-if="paymentStatus === 'sending'" class="status sending">
<div class="spinner"></div>
<p>Waiting for wallet approval...</p>
</div>
<div v-else-if="paymentStatus === 'confirming'" class="status confirming">
<div class="spinner"></div>
<p>Payment sent! Waiting for confirmations...</p>
<div class="confirmations">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: confirmationProgress + '%' }"
></div>
</div>
<p>{{ confirmations }}/{{ currentPayment?.requiredConfirmations }} confirmations</p>
</div>
<a
v-if="txHash"
:href="`https://sepolia.etherscan.io/tx/${txHash}`"
target="_blank"
class="tx-link"
>
View Transaction →
</a>
</div>
<div v-else-if="paymentStatus === 'confirmed'" class="status confirmed">
<div class="success-icon">✓</div>
<h3>Payment Confirmed!</h3>
<p>Your order is being processed</p>
<a
v-if="txHash"
:href="`https://sepolia.etherscan.io/tx/${txHash}`"
target="_blank"
class="tx-link"
>
View Transaction →
</a>
</div>
<div v-else-if="paymentStatus === 'failed'" class="status failed">
<div class="error-icon">✕</div>
<h3>Payment Failed</h3>
<p>{{ error }}</p>
<button @click="retryPayment" class="btn-secondary">
Try Again
</button>
</div>
</div>
</div>
</div>
<!-- Step 3: Confirmation -->
<div v-else-if="step === 'complete'" class="step">
<div class="success-screen">
<div class="success-icon-large">✓</div>
<h2>Order Complete!</h2>
<p>Thank you for your purchase</p>
<div class="order-details">
<p><strong>Order ID:</strong> {{ currentPayment?.orderId }}</p>
<p><strong>Amount Paid:</strong> {{ currentPayment?.amount }} {{ currentPayment?.currency }}</p>
<p><strong>Transaction:</strong></p>
<a
:href="`https://sepolia.etherscan.io/tx/${txHash}`"
target="_blank"
class="tx-link-large"
>
{{ shortenTxHash(txHash) }} →
</a>
</div>
<button @click="startNewOrder" class="btn-primary">
Place Another Order
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useWallet } from '../composables/useWallet'
import { usePayments } from '../composables/usePayments'
import QRCode from 'qrcode'
const { account, isConnected } = useWallet()
const {
currentPayment,
paymentStatus,
confirmations,
txHash,
error,
getExpiryTime,
isExpired,
createPaymentInvoice,
sendETHPayment,
sendTokenPayment,
resetPayment
} = usePayments()
// Component state
const step = ref('create') // create, payment, complete
const selectedCurrency = ref('ETH')
const qrCanvas = ref(null)
// Product example (in real app, this would come from props or store)
const product = ref({
name: 'Premium Subscription',
price: 0.01,
orderId: `ORDER-${Date.now()}`
})
// Merchant wallet address (IMPORTANT: Use your own address in production!)
const MERCHANT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb9'
// Supported payment currencies
const supportedCurrencies = [
{ symbol: 'ETH', icon: '⟠', address: null },
{ symbol: 'USDC', icon: '💵', address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' }, // Sepolia USDC
{ symbol: 'DAI', icon: '◈', address: '0x68194a729C2450ad26072b3D33ADaCbcef39D574' } // Sepolia DAI
]
// Create order and payment invoice
const createOrder = () => {
const currency = supportedCurrencies.find(c => c.symbol === selectedCurrency.value)
createPaymentInvoice({
amount: product.value.price,
currency: currency.symbol,
tokenAddress: currency.address,
recipientAddress: MERCHANT_ADDRESS,
orderId: product.value.orderId,
description: product.value.name,
requiredConfirmations: 2 // Lower for testing, use 6+ in production
})
step.value = 'payment'
// Generate QR code after DOM updates
setTimeout(generateQRCode, 100)
}
// Generate QR code for mobile payments
const generateQRCode = async () => {
if (!qrCanvas.value || !currentPayment.value) return
const currency = supportedCurrencies.find(c => c.symbol === selectedCurrency.value)
let qrData
if (currency.symbol === 'ETH') {
// EIP-681 format for ETH
qrData = `ethereum:${MERCHANT_ADDRESS}?value=${currentPayment.value.amount}e18`
} else {
// EIP-681 format for ERC-20 tokens
qrData = `ethereum:${currency.address}/transfer?address=${MERCHANT_ADDRESS}&uint256=${currentPayment.value.amount}e18`
}
try {
await QRCode.toCanvas(qrCanvas.value, qrData, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
} catch (err) {
console.error('QR code generation failed:', err)
}
}
// Send payment from connected wallet
const handleSendPayment = async () => {
if (!currentPayment.value) return
try {
const currency = supportedCurrencies.find(c => c.symbol === selectedCurrency.value)
if (currency.symbol === 'ETH') {
await sendETHPayment(MERCHANT_ADDRESS, currentPayment.value.amount)
} else {
await sendTokenPayment(currency.address, MERCHANT_ADDRESS, currentPayment.value.amount)
}
} catch (err) {
console.error('Payment error:', err)
}
}
// Payment button text
const paymentButtonText = computed(() => {
if (isExpired.value) return 'Payment Expired'
if (paymentStatus.value === 'sending') return 'Sending...'
if (paymentStatus.value === 'confirming') return 'Confirming...'
if (paymentStatus.value === 'confirmed') return 'Payment Confirmed'
return `Pay ${currentPayment.value?.amount} ${selectedCurrency.value}`
})
// Confirmation progress percentage
const confirmationProgress = computed(() => {
if (!currentPayment.value) return 0
return (confirmations.value / currentPayment.value.requiredConfirmations) * 100
})
// Retry payment
const retryPayment = () => {
resetPayment()
step.value = 'create'
}
// Start new order
const startNewOrder = () => {
resetPayment()
step.value = 'create'
product.value.orderId = `ORDER-${Date.now()}`
}
// Helper: Shorten address
const shortenAddress = (address) => {
if (!address) return ''
return `${address.slice(0, 6)}...${address.slice(-4)}`
}
// Helper: Shorten tx hash
const shortenTxHash = (hash) => {
if (!hash) return ''
return `${hash.slice(0, 10)}...${hash.slice(-8)}`
}
// Watch for payment confirmation
watch(paymentStatus, (status) => {
if (status === 'confirmed') {
setTimeout(() => {
step.value = 'complete'
}, 2000)
}
})
// Cleanup on unmount
onUnmounted(() => {
resetPayment()
})
</script>
<style scoped>
.checkout-flow {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.checkout-container {
background: white;
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
padding: 2rem;
}
h2 {
font-size: 1.8rem;
margin-bottom: 1.5rem;
color: #333;
}
h3 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: #555;
}
/* Order Summary */
.order-summary {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 2rem;
}
.item {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #dee2e6;
}
.total {
display: flex;
justify-content: space-between;
padding-top: 1rem;
font-weight: 600;
font-size: 1.2rem;
}
.amount {
color: #667eea;
}
/* Currency Selector */
.payment-method {
margin-bottom: 2rem;
}
.currency-selector {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.currency-btn {
padding: 1rem;
border: 2px solid #e0e0e0;
background: white;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.currency-btn:hover {
border-color: #667eea;
}
.currency-btn.active {
border-color: #667eea;
background: #f0f4ff;
color: #667eea;
}
/* Payment Details */
.payment-details {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 1.5rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
}
.label {
color: #6c757d;
font-weight: 500;
}
.value {
font-weight: 600;
color: #333;
}
.value.address {
font-family: monospace;
font-size: 0.9rem;
}
.value.expired {
color: #dc3545;
}
/* QR Code */
.qr-code-section {
text-align: center;
margin: 2rem 0;
}
.qr-code {
display: inline-block;
padding: 1rem;
background: white;
border: 2px solid #e0e0e0;
border-radius: 12px;
margin: 1rem 0;
}
.qr-hint {
color: #6c757d;
font-size: 0.9rem;
}
/* Divider */
.divider {
text-align: center;
margin: 2rem 0;
color: #6c757d;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: #dee2e6;
}
.divider::before {
left: 0;
}
.divider::after {
right: 0;
}
/* Buttons */
.btn-primary, .btn-secondary {
padding: 1rem 2rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
margin-top: 1rem;
}
.btn-large {
font-size: 1.1rem;
padding: 1.25rem;
}
/* Payment Status */
.payment-status {
margin-top: 2rem;
}
.status {
text-align: center;
padding: 2rem;
border-radius: 12px;
}
.status.sending,
.status.confirming {
background: #fff3cd;
}
.status.confirmed {
background: #d4edda;
}
.status.failed {
background: #f8d7da;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.success-icon {
width: 60px;
height: 60px;
background: #28a745;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
margin: 0 auto 1rem;
}
.error-icon {
width: 60px;
height: 60px;
background: #dc3545;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
margin: 0 auto 1rem;
}
.confirmations {
margin: 1.5rem 0;
}
.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;
}
.tx-link {
display: inline-block;
margin-top: 1rem;
color: #667eea;
font-weight: 600;
text-decoration: none;
}
.tx-link:hover {
text-decoration: underline;
}
/* Success Screen */
.success-screen {
text-align: center;
padding: 2rem 0;
}
.success-icon-large {
width: 100px;
height: 100px;
background: #28a745;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin: 0 auto 1.5rem;
animation: scaleIn 0.5s;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.order-details {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 12px;
margin: 2rem 0;
text-align: left;
}
.tx-link-large {
display: inline-block;
margin-top: 0.5rem;
color: #667eea;
font-weight: 600;
text-decoration: none;
font-family: monospace;
}
.connect-prompt {
text-align: center;
padding: 3rem 2rem;
}
</style>
Handling Payment Webhooks (Backend)
For production applications, you'll want a backend service to monitor payments and update order status. Here's a Node.js example:
Backend Payment Monitor (Node.js + Express)
// payment-monitor.js
import { ethers } from 'ethers'
import express from 'express'
const app = express()
app.use(express.json())
// Connect to Ethereum node
const provider = new ethers.JsonRpcProvider('https://sepolia.infura.io/v3/YOUR_INFURA_KEY')
// Merchant wallet address
const MERCHANT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb9'
// In-memory payment tracking (use database in production)
const pendingPayments = new Map()
/**
* Create new payment invoice
*/
app.post('/api/payments/create', async (req, res) => {
const { orderId, amount, currency, tokenAddress } = req.body
const invoiceId = `INV-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const payment = {
invoiceId,
orderId,
amount,
currency,
tokenAddress,
recipientAddress: MERCHANT_ADDRESS,
status: 'pending',
createdAt: Date.now(),
expiresAt: Date.now() + (30 * 60 * 1000), // 30 minutes
requiredConfirmations: 6
}
pendingPayments.set(invoiceId, payment)
// Start monitoring for this payment
monitorPayment(invoiceId)
res.json({
success: true,
invoice: payment
})
})
/**
* Get payment status
*/
app.get('/api/payments/:invoiceId', (req, res) => {
const payment = pendingPayments.get(req.params.invoiceId)
if (!payment) {
return res.status(404).json({ error: 'Invoice not found' })
}
res.json(payment)
})
/**
* Monitor blockchain for payment
*/
async function monitorPayment(invoiceId) {
const payment = pendingPayments.get(invoiceId)
if (!payment) return
console.log(`👂 Monitoring payment ${invoiceId}`)
if (payment.currency === 'ETH') {
// Monitor ETH transfers
provider.on('block', async (blockNumber) => {
const block = await provider.getBlock(blockNumber, true)
for (const tx of block.transactions) {
if (tx.to?.toLowerCase() === MERCHANT_ADDRESS.toLowerCase()) {
const amountInEth = ethers.formatUnits(tx.value, 18)
if (parseFloat(amountInEth) >= parseFloat(payment.amount)) {
console.log(`💰 Payment received for ${invoiceId}`)
payment.txHash = tx.hash
payment.status = 'confirming'
// Wait for confirmations
await waitForConfirmations(invoiceId, tx.hash)
}
}
}
})
} else {
// Monitor ERC-20 token transfers
const ERC20_ABI = [
'event Transfer(address indexed from, address indexed to, uint256 value)'
]
const contract = new ethers.Contract(payment.tokenAddress, ERC20_ABI, provider)
contract.on('Transfer', async (from, to, amount, event) => {
if (to.toLowerCase() === MERCHANT_ADDRESS.toLowerCase()) {
const decimals = await contract.decimals()
const amountInTokens = ethers.formatUnits(amount, decimals)
if (parseFloat(amountInTokens) >= parseFloat(payment.amount)) {
console.log(`💰 Token payment received for ${invoiceId}`)
payment.txHash = event.log.transactionHash
payment.status = 'confirming'
await waitForConfirmations(invoiceId, event.log.transactionHash)
}
}
})
}
// Check for expiry
setTimeout(() => {
if (payment.status === 'pending') {
payment.status = 'expired'
console.log(`⏰ Payment ${invoiceId} expired`)
provider.removeAllListeners()
}
}, 30 * 60 * 1000)
}
/**
* Wait for required confirmations
*/
async function waitForConfirmations(invoiceId, txHash) {
const payment = pendingPayments.get(invoiceId)
if (!payment) return
const interval = setInterval(async () => {
const receipt = await provider.getTransactionReceipt(txHash)
if (receipt) {
const currentBlock = await provider.getBlockNumber()
const confirmations = currentBlock - receipt.blockNumber
payment.confirmations = confirmations
console.log(`⏳ ${invoiceId}: ${confirmations}/${payment.requiredConfirmations} confirmations`)
if (confirmations >= payment.requiredConfirmations) {
clearInterval(interval)
payment.status = 'confirmed'
payment.confirmedAt = Date.now()
console.log(`✅ Payment ${invoiceId} confirmed!`)
// Fulfill order here
fulfillOrder(payment)
// Cleanup
provider.removeAllListeners()
}
}
}, 10000) // Check every 10 seconds
}
/**
* Fulfill order after payment confirmation
*/
async function fulfillOrder(payment) {
console.log(`📦 Fulfilling order ${payment.orderId}`)
// Add your order fulfillment logic here:
// - Update database
// - Send confirmation email
// - Trigger delivery
// - Update inventory
// etc.
// Example webhook to your main backend
try {
await fetch('https://your-app.com/api/orders/fulfill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orderId: payment.orderId,
invoiceId: payment.invoiceId,
txHash: payment.txHash,
amount: payment.amount,
currency: payment.currency
})
})
} catch (err) {
console.error('Failed to fulfill order:', err)
}
}
app.listen(3001, () => {
console.log('💳 Payment monitor running on port 3001')
})
Security Best Practices
1. Address Validation
import { isAddress, getAddress } from 'ethers'
function validateAndNormalizeAddress(address) {
if (!isAddress(address)) {
throw new Error('Invalid Ethereum address')
}
// Returns checksum address
return getAddress(address)
}
2. Amount Validation
function validatePaymentAmount(receivedAmount, expectedAmount, tolerance = 0.001) {
const received = parseFloat(receivedAmount)
const expected = parseFloat(expectedAmount)
// Check if received amount is within tolerance
if (received < expected - tolerance) {
return { valid: false, reason: 'Underpayment' }
}
if (received > expected + tolerance) {
return { valid: false, reason: 'Overpayment' }
}
return { valid: true }
}
3. Rate Limiting
// Implement rate limiting for invoice creation
const invoiceCreationLimits = new Map()
function checkRateLimit(userId) {
const now = Date.now()
const userLimit = invoiceCreationLimits.get(userId) || { count: 0, resetAt: now + 60000 }
if (now > userLimit.resetAt) {
userLimit.count = 0
userLimit.resetAt = now + 60000
}
if (userLimit.count >= 10) {
throw new Error('Rate limit exceeded. Try again later.')
}
userLimit.count++
invoiceCreationLimits.set(userId, userLimit)
}
Testing Your Payment System
Test Checklist
- ✅ Create invoice with ETH
- ✅ Create invoice with ERC-20 tokens
- ✅ Send payment from connected wallet
- ✅ Scan QR code with mobile wallet
- ✅ Monitor confirmations progress
- ✅ Handle payment expiry
- ✅ Test underpayment rejection
- ✅ Test overpayment handling
- ✅ Verify transaction on block explorer
- ✅ Test on mainnet with small amounts
Get Testnet Funds
- Sepolia ETH: sepoliafaucet.com
- Sepolia USDC: Use Circle Faucet
- Mumbai MATIC: faucet.polygon.technology
Common Issues & Solutions
| Issue | Cause | Solution |
|---|---|---|
| Payment not detected | Event listener not active | Ensure listener starts before payment sent |
| Wrong confirmation count | Block number calculation off | Use receipt.blockNumber, not block.number |
| Underpayment accepted | No amount validation | Always verify received amount >= expected |
| QR code not working | Invalid EIP-681 format | Follow ethereum: URI scheme exactly |
| Token payment fails | No approval given | Some wallets require approve() first |
Production Deployment Tips
1. Use Dedicated RPC Nodes
Don't rely on public RPCs in production. Use services like:
2. Increase Required Confirmations
const CONFIRMATION_REQUIREMENTS = {
'small': 6, // < $100
'medium': 12, // $100 - $1000
'large': 20 // > $1000
}
function getRequiredConfirmations(amountUSD) {
if (amountUSD < 100) return CONFIRMATION_REQUIREMENTS.small
if (amountUSD < 1000) return CONFIRMATION_REQUIREMENTS.medium
return CONFIRMATION_REQUIREMENTS.large
}
3. Implement Database Storage
// Example Prisma schema
model Payment {
id String @id @default(cuid())
invoiceId String @unique
orderId String
amount Decimal
currency String
tokenAddress String?
recipientAddress String
status String // pending, confirming, confirmed, expired, failed
txHash String?
confirmations Int @default(0)
requiredConfirmations Int @default(6)
createdAt DateTime @default(now())
confirmedAt DateTime?
expiresAt DateTime
}
What's Next?
You now have a complete crypto payment system! Next, we'll cover sending payments to users:
- Part 4: Batch payments, gas optimization, payment queues, withdrawal systems
- Part 5: Security auditing, penetration testing, production monitoring, incident response