Web3 Integration Guide Part 3: Accepting Cryptocurrency Payments

Complete guide to accepting crypto payments in your web application. Learn to handle ETH and ERC-20 tokens, build payment flows, generate invoices, and track payment status with real-time confirmation.

By GodFake Team22 min read
Web3PaymentsCryptocurrencyEthereumVue 3Ethers.jsE-commerce
Web3 Integration Guide

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

  1. Generate payment address: Create unique address for each order
  2. Display payment details: Show amount, address, QR code
  3. Monitor blockchain: Listen for incoming transactions
  4. Verify payment: Confirm correct amount and token
  5. Wait for confirmations: Ensure transaction finality
  6. 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

  1. ✅ Create invoice with ETH
  2. ✅ Create invoice with ERC-20 tokens
  3. ✅ Send payment from connected wallet
  4. ✅ Scan QR code with mobile wallet
  5. ✅ Monitor confirmations progress
  6. ✅ Handle payment expiry
  7. ✅ Test underpayment rejection
  8. ✅ Test overpayment handling
  9. ✅ Verify transaction on block explorer
  10. ✅ Test on mainnet with small amounts

Get Testnet Funds

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

Resources