The structure of P2P escrow state transitions
Peer-to-peer (P2P) crypto trading platforms operate as state machines. Because direct cryptocurrency card deposits are restricted, retail traders rely on bank transfers to settle trades. Services like Moniepoint, OPay, Kuda Bank, or traditional commercial bank transfers settled via the NIBSS Instant Payments (NIP) network act as the fiat leg, while the platform's escrow wallet holds the crypto.
Here is how a clean P2P escrow transaction runs:
- Order Creation: A buyer creates a buy order for 100 USDT from a seller's listing.
- Escrow Lock: The platform matching engine locks the 100 USDT from the seller's balance into a secure escrow wallet.
- Naira Transfer: The buyer sends Naira via their bank app to the seller's bank account details.
- Buyer Mark-Paid: The buyer clicks 'I have paid' on the platform.
- Seller Statement Check: The seller verifies their bank statement to confirm the Naira actually landed.
- Seller Confirmation: The seller clicks 'Confirm received'.
- Release Execution: The platform releases the 100 USDT from escrow to the buyer's wallet.
While this flow seems straightforward, the interface between the off-chain bank network and the on-platform database creates serious logical loopholes. If developers do not build strict validation checks on state transitions, malicious actors can bypass confirmation steps entirely.
The buyer self-releases vulnerability
The first major logic flaw occurs when the endpoint responsible for executing the final crypto release checks only if the requester is authenticated, but fails to check which role the requester holds.
Consider this API call made by a malicious buyer:
POST /api/p2p/orders/12345/release
Authorization: Bearer [buyer_jwt_token]
Content-Type: application/json
{
"orderId": 12345
} If the backend checks status === 'in_progress' but does not check the seller confirmation boolean, the endpoint performs the release. The buyer calls this endpoint from their own client using their own JWT token. The crypto leaves the escrow wallet and lands in the buyer's balance without the seller ever clicking confirm.
The race condition release
Another threat vector is the race condition during the state transitions. When a buyer initiates the "I have paid" action and the "Release" action at almost the same millisecond, multi-threaded backend architectures or asynchronous event loops can experience race conditions.
If the database queries and updates are not isolated inside a strict transaction block, two parallel requests might read the state of the order simultaneously.
Let us trace the execution timing:
- Request 1 (
POST /api/p2p/orders/12345/mark-paid) arrives. The thread queries the order. It sees the status isLOCKED. - Request 2 (
POST /api/p2p/orders/12345/release) arrives concurrently. The thread queries the order. Because Request 1 has not yet written the updated status to the database, Request 2 also reads the status asLOCKED. - If the release endpoint permits release from a
LOCKEDstate under specific fallback conditions, both requests proceed. - The database writes the status as
PAIDfor Request 1. - The database writes the status as
RELEASEDfor Request 2, transferring the USDT to the buyer.
Without database-level locking, the state checks operate on stale data. An attacker can write a script to fire these requests in parallel, exploiting the delay between checking the order status and updating the ledger.
Seller impersonation via BOLA
Broken Object Level Authorization (BOLA) remains the most prevalent API vulnerability we find in African fintech platforms. In P2P exchanges, BOLA allows an attacker to impersonate the seller and release the escrow manually.
This happens when the platform uses sequential, predictable IDs for orders. For example, Order A has ID 12345, and Order B has ID 12346. If an attacker starts a trade, they know their order ID is 12345. They can predict that other active trades on the platform have IDs like 12346 or 12347.
The attacker targets the seller's confirmation endpoint:
POST /api/p2p/orders/12346/confirm-received
Authorization: Bearer [buyer_jwt_token]
The attacker sends their own JWT token in the Authorization header. If the API checks that the user is logged in, but fails to check whether the authenticated user ID matches the sellerId recorded in the order table for ID 12346, the API executes the call. The platform changes the order status to confirmed and releases the crypto. The attacker can use this to release crypto for their own order without paying, or disrupt the entire exchange by systematically releasing escrow for every active order on the platform.
Social engineering: Spoofed SMS bank alerts
Logic flaws are not limited to API parameters. Attackers exploit the human interface of P2P: bank alerts. Because banking apps in Nigeria often suffer from high network latency via NIBSS, traders frequently rely on SMS alerts to confirm incoming funds.
An attacker initiates a buy order for ₦450,000. Instead of transferring the funds, they use a bulk SMS gateway to send a fake credit alert to the seller's phone number. The sender ID is spoofed to match traditional banks like "GTBank" or "AccessBank". The message is structured to match standard banking templates.
The seller sees the SMS, assumes the funds have settled, and clicks "Confirm Received" in the exchange app. The platform releases the crypto. By the time the seller logs into their bank app and realizes the balance did not change, the buyer has already withdrawn the crypto from the exchange.
Business impact: Naira drain and brand collapse
For a Nigerian cryptocurrency exchange, escrow logic flaws are fatal:
- Direct Financial Loss: A single exploited order can drain ₦50,000 to ₦500,000 in USDT or USDC. Fraudulent syndicates automate these exploits, draining millions of Naira in assets before the engineering team flags the anomaly.
- Regulatory Penalties: The Nigeria Data Protection Act (NDPA) establishes strict guidelines for protecting customer data. Exploits that expose PII or order details risk fines of up to ₦10 million or 2% of annual gross revenue.
- Funding & Investor Risk: Venture capital firms and local fintech partners will pull back. During due diligence, a logic flaw that permits unilateral escrow draining represents a red flag that terminates term sheet discussions.
- Reputational Collapse: If market makers and liquidity providers cannot guarantee the safety of their locked crypto assets, they will leave the platform. A P2P platform without liquidity is dead.
The fix: Hardening the P2P escrow state machine
To secure your P2P engine, you must implement strict access controls and transactional guarantees. Below is a secure implementation pattern using Prisma 7 and PostgreSQL.
Strict access control and write locking
Both the confirmation and release endpoints must be protected by explicit ownership checks and database-level locking. This pattern prevents BOLA and race conditions:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Verifies bank payment and confirms receipt
export async function confirmReceived(req, res) {
const { orderId } = req.body;
const sellerId = req.user.id; // Extracted from verified JWT
try {
const updatedOrder = await prisma.$transaction(async (tx) => {
// 1. Fetch order with a row-level write lock (pessimistic lock)
const order = await tx.$queryRaw`
SELECT * FROM "Order"
WHERE id = ${orderId}
FOR UPDATE
`;
if (!order || order.length === 0) {
throw new Error("ORDER_NOT_FOUND");
}
const activeOrder = order[0];
// 2. Prevent BOLA: Verify the caller is the seller
if (activeOrder.sellerId !== sellerId) {
throw new Error("UNAUTHORIZED_SELLER");
}
// 3. Verify order is in the correct state
if (activeOrder.status !== "LOCKED") {
throw new Error("INVALID_STATUS");
}
// 4. Update status to confirm receipt
return await tx.order.update({
where: { id: orderId },
data: {
sellerConfirmed: true,
status: "BUYER_PAID_AND_CONFIRMED"
}
});
});
return res.status(200).json({ success: true, order: updatedOrder });
} catch (error) {
if (error.message === "UNAUTHORIZED_SELLER") {
return res.status(403).json({ error: "Access Denied: You are not the seller" });
}
return res.status(400).json({ error: error.message });
}
}
// Executes the release of funds to the buyer
export async function releaseFunds(req, res) {
const { orderId } = req.body;
try {
const transactionResult = await prisma.$transaction(async (tx) => {
// Acquire write lock to prevent race conditions
const order = await tx.$queryRaw`
SELECT * FROM "Order"
WHERE id = ${orderId}
FOR UPDATE
`;
if (!order || order.length === 0) {
throw new Error("ORDER_NOT_FOUND");
}
const activeOrder = order[0];
// Verify the seller has confirmed the payment
if (!activeOrder.sellerConfirmed || activeOrder.status !== "BUYER_PAID_AND_CONFIRMED") {
throw new Error("PAYMENT_NOT_CONFIRMED");
}
// Update state to released
const updatedOrder = await tx.order.update({
where: { id: orderId },
data: { status: "RELEASED" }
});
// Transfer assets between wallets
await tx.wallet.update({
where: { id: activeOrder.escrowWalletId },
data: { balance: { decrement: activeOrder.cryptoAmount } }
});
await tx.wallet.update({
where: { id: activeOrder.buyerWalletId },
data: { balance: { increment: activeOrder.cryptoAmount } }
});
return updatedOrder;
});
return res.status(200).json({ success: true, order: transactionResult });
} catch (error) {
return res.status(400).json({ error: error.message });
}
} Automated payment verification
To eliminate SMS spoofing and human error, integrate with bank statement APIs like Mono or Okra. Instead of relying solely on the seller clicking "Confirm", the platform can poll the seller's business account bank statement via API using a unique transaction reference code in the bank transfer narration (e.g. REF-12345). Only enable the confirmation button when the Mono API returns a matching credit entry.
Dispute escrow holds
For large-value transactions, do not release the crypto instantly. Hold the assets in a pending-release state for 15 minutes. This delay gives the seller a window to raise a dispute flag if they realize they confirmed a transaction based on a fake alert or network error.
Real-time WebSocket alerts
Provide real-time updates of the order state machine. When the seller confirms, push the state change to both client apps via WebSockets. This eliminates client-side polling delays and prevents users from making decisions based on outdated UI states.
Automated vulnerability scanners check individual endpoints for common patterns; they cannot model the logical state machine of a P2P transaction flow across two distinct authenticated sessions, which is why identifying these flaws requires manual code review.
Escrow Release via Insecure State Transition
During an API security review of a local cryptocurrency exchange, we created a buy order, marked the payment as paid without transferring Naira, and then queried the release endpoint directly using the buyer's session token. The backend processed the release, transferring the test USDT to our buyer wallet. Fix priority was critical.
Are your core transaction endpoints vulnerable to object manipulation?
Get a security reviewFrequently asked questions
Should P2P platforms verify payments server-side?
Yes, for any trade above ₦50,000. Paystack's transfer verification API and bank statement APIs from providers like Mono or Okra allow automated payment confirmation.
What if we use a third-party P2P engine?
Your risk is in the integration layer: how your platform passes order status to/from the P2P engine.
How long should crypto be held in escrow after release is triggered?
We recommend 15 minutes as a dispute window.
Related reading
Blog: BOLA in Payment Platforms · Rate Limiting for Payments · Webhook Security
Services: API security testing · Penetration testing