Skip to main content

Command Palette

Search for a command to run...

Building a P2P Crypto Escrow System for Nigeria

Updated
7 min read

The escrow system is the heart of any P2P crypto exchange. It's what separates a trustworthy Nigerian P2P platform from a scam. This guide covers both smart contract and backend escrow implementation.

Option 1 — Smart Contract Escrow (Trustless)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract NigerianP2PEscrow is ReentrancyGuard {
    
    enum TradeStatus { 
        PENDING,      // Buyer initiated, waiting for payment
        PAID,         // Buyer confirmed payment sent
        COMPLETED,    // Seller released crypto
        DISPUTED,     // Under dispute review
        CANCELLED     // Trade cancelled
    }
    
    struct Trade {
        address seller;
        address buyer;
        address token;        // USDT contract address
        uint256 amount;       // Crypto amount in escrow
        uint256 nairaAmount;  // NGN amount (off-chain reference)
        string paymentMethod; // "GTBank", "OPay" etc
        TradeStatus status;
        uint256 createdAt;
        uint256 paymentDeadline; // 15 minutes
    }
    
    mapping(bytes32 => Trade) public trades;
    address public disputeResolver;
    uint256 public platformFee = 50; // 0.5% in basis points
    
    event TradeCreated(bytes32 tradeId, address seller, address buyer, uint256 amount);
    event PaymentConfirmed(bytes32 tradeId);
    event CryptoReleased(bytes32 tradeId);
    event TradeDisputed(bytes32 tradeId);
    
    constructor(address _disputeResolver) {
        disputeResolver = _disputeResolver;
    }
    
    // Seller locks USDT in escrow
    function createTrade(
        address _buyer,
        address _token,
        uint256 _amount,
        uint256 _nairaAmount,
        string memory _paymentMethod
    ) external nonReentrant returns (bytes32) {
        
        require(_amount > 0, "Amount must be greater than 0");
        
        // Transfer USDT from seller to escrow
        IERC20(_token).transferFrom(msg.sender, address(this), _amount);
        
        bytes32 tradeId = keccak256(abi.encodePacked(
            msg.sender, _buyer, _amount, block.timestamp
        ));
        
        trades[tradeId] = Trade({
            seller: msg.sender,
            buyer: _buyer,
            token: _token,
            amount: _amount,
            nairaAmount: _nairaAmount,
            paymentMethod: _paymentMethod,
            status: TradeStatus.PENDING,
            createdAt: block.timestamp,
            paymentDeadline: block.timestamp + 15 minutes
        });
        
        emit TradeCreated(tradeId, msg.sender, _buyer, _amount);
        return tradeId;
    }
    
    // Buyer confirms Naira payment sent
    function confirmPayment(bytes32 tradeId) external {
        Trade storage trade = trades[tradeId];
        require(msg.sender == trade.buyer, "Only buyer can confirm");
        require(trade.status == TradeStatus.PENDING, "Invalid status");
        require(block.timestamp <= trade.paymentDeadline, "Payment deadline passed");
        
        trade.status = TradeStatus.PAID;
        emit PaymentConfirmed(tradeId);
    }
    
    // Seller releases crypto after confirming NGN received
    function releaseCrypto(bytes32 tradeId) external nonReentrant {
        Trade storage trade = trades[tradeId];
        require(msg.sender == trade.seller, "Only seller can release");
        require(
            trade.status == TradeStatus.PAID || 
            trade.status == TradeStatus.PENDING,
            "Invalid status"
        );
        
        // Calculate platform fee
        uint256 fee = (trade.amount * platformFee) / 10000;
        uint256 buyerAmount = trade.amount - fee;
        
        trade.status = TradeStatus.COMPLETED;
        
        // Transfer to buyer
        IERC20(trade.token).transfer(trade.buyer, buyerAmount);
        
        emit CryptoReleased(tradeId);
    }
    
    // Either party can open a dispute
    function openDispute(bytes32 tradeId) external {
        Trade storage trade = trades[tradeId];
        require(
            msg.sender == trade.buyer || msg.sender == trade.seller,
            "Only trade parties"
        );
        require(trade.status == TradeStatus.PAID, "Can only dispute after payment");
        
        trade.status = TradeStatus.DISPUTED;
        emit TradeDisputed(tradeId);
    }
    
    // Dispute resolver decides outcome
    function resolveDispute(
        bytes32 tradeId, 
        bool releaseToBuyer
    ) external nonReentrant {
        require(msg.sender == disputeResolver, "Only resolver");
        Trade storage trade = trades[tradeId];
        require(trade.status == TradeStatus.DISPUTED, "Not disputed");
        
        trade.status = TradeStatus.COMPLETED;
        
        address recipient = releaseToBuyer ? trade.buyer : trade.seller;
        IERC20(trade.token).transfer(recipient, trade.amount);
    }
    
    // Cancel expired trade
    function cancelExpiredTrade(bytes32 tradeId) external nonReentrant {
        Trade storage trade = trades[tradeId];
        require(trade.status == TradeStatus.PENDING, "Not pending");
        require(block.timestamp > trade.paymentDeadline, "Not expired");
        
        trade.status = TradeStatus.CANCELLED;
        
        // Return USDT to seller
        IERC20(trade.token).transfer(trade.seller, trade.amount);
    }
}

Option 2 — Custodial Backend Escrow (For TRC20 USDT)

TRC20 USDT is most popular in Nigeria — cheap fees (~$1). Here's the backend escrow implementation:

const TronWeb = require('tronweb');
const { v4: uuidv4 } = require('uuid');

class NigerianEscrowService {
  constructor(db) {
    this.db = db;
    this.tronWeb = new TronWeb({
      fullHost: 'https://api.trongrid.io',
      privateKey: process.env.ESCROW_WALLET_KEY
    });
    this.usdtContract = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
    this.escrowAddress = process.env.ESCROW_WALLET_ADDRESS;
  }

  // Lock seller's USDT in escrow wallet
  async lockInEscrow(sellerId, amount, tradeDetails) {
    // Verify seller has sent USDT to escrow wallet
    const received = await this.verifyUSDTReceived(
      this.escrowAddress, 
      amount,
      tradeDetails.depositTxHash
    );
    
    if (!received) {
      throw new Error('USDT not received in escrow wallet');
    }

    const trade = await this.db.Trade.create({
      id: uuidv4(),
      sellerId,
      buyerId: tradeDetails.buyerId,
      cryptoAmount: amount,
      nairaAmount: tradeDetails.nairaAmount,
      paymentMethod: tradeDetails.paymentMethod,
      status: 'LOCKED',
      escrowTxHash: tradeDetails.depositTxHash,
      paymentDeadline: new Date(Date.now() + 15 * 60 * 1000),
      buyerWallet: tradeDetails.buyerWallet
    });

    // Notify both parties via WhatsApp
    await this.notifyWhatsApp(
      tradeDetails.sellerPhone,
      `🔒 Trade #${trade.id.slice(0,8)} created.\n` +
      `${amount} USDT locked in escrow.\n` +
      `Waiting for buyer payment of ₦${tradeDetails.nairaAmount.toLocaleString()}`
    );

    await this.notifyWhatsApp(
      tradeDetails.buyerPhone,
      `💰 Trade #${trade.id.slice(0,8)} ready.\n` +
      `Send ₦${tradeDetails.nairaAmount.toLocaleString()} to:\n` +
      `\({tradeDetails.sellerBankName}: \){tradeDetails.sellerAccountNumber}\n` +
      `Account Name: ${tradeDetails.sellerAccountName}\n` +
      `⏰ Payment deadline: 15 minutes`
    );

    return trade;
  }

  // Release USDT from escrow to buyer
  async releaseToBuyer(tradeId, sellerId) {
    const trade = await this.db.Trade.findOne({
      where: { id: tradeId, sellerId, status: 'PAYMENT_CONFIRMED' }
    });

    if (!trade) throw new Error('Trade not found or invalid status');

    // Send USDT from escrow to buyer wallet
    const contract = await this.tronWeb.contract().at(this.usdtContract);
    const amount = trade.cryptoAmount * 1_000_000; // Convert to sun

    const tx = await contract
      .transfer(trade.buyerWallet, amount)
      .send({ feeLimit: 100_000_000 });

    await this.db.Trade.update(
      { status: 'COMPLETED', releaseTxHash: tx },
      { where: { id: tradeId } }
    );

    // Notify buyer
    await this.notifyWhatsApp(
      trade.buyerPhone,
      `✅ Trade #${tradeId.slice(0,8)} complete!\n` +
      `${trade.cryptoAmount} USDT sent to your wallet.\n` +
      `TX: ${tx}`
    );

    return { success: true, txHash: tx };
  }

  // WhatsApp notification via Twilio or WhatsApp Business API
  async notifyWhatsApp(phone, message) {
    await fetch('https://api.twilio.com/2010-04-01/Accounts/' +
      process.env.TWILIO_SID + '/Messages.json', {
      method: 'POST',
      headers: {
        'Authorization': 'Basic ' + Buffer.from(
          process.env.TWILIO_SID + ':' + process.env.TWILIO_TOKEN
        ).toString('base64'),
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        From: 'whatsapp:+14155238886',
        To: `whatsapp:${phone}`,
        Body: message
      })
    });
  }

  async verifyUSDTReceived(address, expectedAmount, txHash) {
    try {
      const tx = await this.tronWeb.trx.getTransaction(txHash);
      // Verify transaction details match expected
      return tx && tx.ret[0].contractRet === 'SUCCESS';
    } catch { return false; }
  }
}

Nigerian P2P — Order Book Implementation

class NigerianOrderBook {
  constructor(db) {
    this.db = db;
  }

  // Get best sell orders for a buyer
  async getBestSellOrders(cryptoAsset, nairaAmount) {
    return await this.db.Order.findAll({
      where: {
        type: 'SELL',
        cryptoAsset,
        status: 'ACTIVE',
        minOrderSize: { [Op.lte]: nairaAmount },
        maxOrderSize: { [Op.gte]: nairaAmount }
      },
      order: [['nairaRate', 'ASC']], // Lowest rate first (best for buyer)
      limit: 20,
      include: [{
        model: this.db.User,
        attributes: ['username', 'completedTrades', 'completionRate'],
        where: { kycStatus: 'VERIFIED' } // KYC required for Nigerian compliance
      }]
    });
  }

  // Post a sell order
  async postSellOrder(sellerId, {
    cryptoAsset, cryptoAmount, nairaRate,
    minOrderSize, maxOrderSize, paymentMethods
  }) {
    // Verify seller has sufficient balance in escrow
    const balance = await this.getEscrowBalance(sellerId, cryptoAsset);
    if (balance < cryptoAmount) {
      throw new Error('Insufficient balance. Deposit crypto first.');
    }

    return await this.db.Order.create({
      sellerId, cryptoAsset, cryptoAmount,
      nairaRate,
      nairaTotal: cryptoAmount * nairaRate,
      minOrderSize, maxOrderSize,
      paymentMethods, // ['GTBank', 'OPay', 'PalmPay']
      status: 'ACTIVE',
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
    });
  }
}

Nigerian P2P Compliance Requirements

// KYC check before allowing trading
const requiresKYC = async (userId, tradeAmount) => {
  const user = await User.findById(userId);
  
  // Tier 1: BVN verified — up to ₦100k/day
  if (tradeAmount <= 100000 && user.bvnVerified) return true;
  
  // Tier 2: ID verified — up to ₦5M/day
  if (tradeAmount <= 5000000 && user.idVerified) return true;
  
  // Tier 3: Full KYC — unlimited
  if (user.fullKycCompleted) return true;
  
  throw new Error(`KYC upgrade required for trades above your current limit`);
};

Production Checklist — Nigerian P2P Exchange

  • [ ] Escrow system tested on testnet before mainnet

  • [ ] 15-minute payment timer with auto-cancel

  • [ ] WhatsApp notifications for every trade event

  • [ ] BVN verification for all users

  • [ ] Daily trading limits per KYC tier

  • [ ] Dispute resolution workflow and team

  • [ ] Nigerian payment method verification

  • [ ] AML transaction monitoring

  • [ ] Mobile-responsive trade interface

  • [ ] NDPR-compliant user data handling


ZikarelHub LTD builds P2P crypto exchange platforms for Nigerian businesses. zikarelhub.tech/crypto-exchange-development-nigeria

More from this blog

T

Tech in Nigeria

12 posts