DEV Community

Wilson
Wilson

Posted on • Originally published at gist.github.com

Concurrent Transactions on Midnight: UTXO Race Conditions & Workarounds

Concurrent Transactions on Midnight: UTXO Race Conditions & Workarounds

The Problem: When Two Transactions Collide

You deploy your first Midnight dApp. Everything works in testing — single transactions sail through. Then you go live, and something strange happens: users report transactions randomly failing with "stale UTXO" or "UTXO already consumed."

This is the UTXO race condition. On Midnight (a UTXO-based chain), the same wallet trying to send two transactions simultaneously can break because both try to spend the same coin.

Pattern 1: Sequential Transaction Queue

The simplest fix is to stop sending transactions concurrently. Queue them:

class SequentialTxQueue {
  private queue = [];
  private processing = false;

  enqueue(txFn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ txFn, resolve, reject });
      this.processQueue();
    });
  }

  async processQueue() {
    if (this.processing || this.queue.length === 0) return;
    this.processing = true;
    const { txFn, resolve, reject } = this.queue.shift();
    try {
      const hash = await txFn();
      resolve(hash);
    } catch (error) {
      reject(error);
    } finally {
      this.processing = false;
      this.processQueue();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Multiple Wallet Instances

For higher throughput, spin up multiple wallets. Each wallet has its own UTXO set:

class WalletPool {
  private wallets = [];

  async acquire() {
    const available = this.wallets.find(w => !w.busy);
    if (available) {
      available.busy = true;
      return available;
    }
    return new Promise(resolve => this.waitQueue.push({ resolve }));
  }

  async release(instance) {
    await this.waitForWalletSync(instance.wallet);
    instance.busy = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Retry with Fresh UTXOs

Add exponential backoff retry for occasional collisions:

async function submitWithRetry(wallet, buildTx, config = { maxRetries: 3 }) {
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
    try {
      await wallet.waitForSync();
      const recipe = await buildTx();
      const proven = await wallet.proveTransaction(recipe);
      return await wallet.submitTransaction(proven);
    } catch (error) {
      if (!isUtxoError(error) || attempt === config.maxRetries) throw error;
      await sleep(Math.min(1000 * 2**attempt, 10000));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Sequential queue for most dApps — simple, reliable
  2. Wallet pool for high-throughput services
  3. Retry + sync as safety net for all cases
  4. Always sync wallet between transactions

Full tutorial: https://gist.github.com/wilsonhoe/a5766903b8c99f9df222ea322e63418b

Midnight Network tutorial series. Bounty #301.

Top comments (0)