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:

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:

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:

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.

Example finding

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 review

Frequently 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