Building a P2P Crypto Escrow System for Nigeria
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
