DEV Community

mmoo9
mmoo9

Posted on

Concurrent Transactions on Midnight: UTXO Race Conditions & Workarounds

Concurrent Transactions on Midnight: UTXO Race Conditions & Workarounds

When you send two transactions from the same Midnight wallet almost simultaneously, one of them will likely fail with a cryptic error. This isn't a bug—it's a fundamental property of how Midnight's UTXO model works. In this tutorial, we'll demystify what's happening under the hood and walk through two production-ready patterns to work around it.


Why Concurrent Transactions from the Same Wallet Fail

The UTXO Model in Brief

Midnight, like Bitcoin and Cardano, uses a UTXO (Unspent Transaction Output) model to track balances rather than accounts. In an account-based model (like Ethereum), your balance is a single number stored at your address. In a UTXO model, your "balance" is actually a collection of discrete coin bundles—UTXOs—that your wallet controls.

Think of it like a physical wallet full of banknotes. If you have $50, you might hold it as a $20, a $20, and a $10. There is no single "$50 balance" to debit; you are spending specific notes.

On Midnight, when your wallet submits a transaction, it:

  1. Scans the blockchain for UTXOs it controls
  2. Selects a subset of those UTXOs to cover the transaction cost (coin selection)
  3. Constructs a transaction that consumes those UTXOs and creates new ones
  4. Broadcasts the transaction to the network

The critical word is consume. A UTXO can only be spent once.

The Race Condition

Here's where concurrent transactions break down. Both transactions are built from the same wallet snapshot and reference the same UTXO. The network accepts the first, but when the second arrives, that UTXO is already spent. Tx2 fails.


The "Stale UTXO" Error

When this happens, Midnight throws a StaleInputError (sometimes surfaced as a TxError with a stale_input or input_spent reason code):

import { TxError } from '@midnight-ntwrk/midnight-js-types';

try {
  await wallet.submitTransaction(tx);
} catch (err) {
  if (err instanceof TxError && err.kind === 'stale_input') {
    console.error('Stale UTXO — retry needed:', err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

UTXO Model vs Account Model

Property UTXO (Midnight, Bitcoin, Cardano) Account (Ethereum, Solana)
Balance representation Collection of unspent outputs Single number at an address
Concurrency Sequential by nature Multiple txs can read same account state
Privacy Easier to build privacy on top Persistent address identifiers
Failure mode Stale UTXO on concurrent spend Nonce collision or gas race

Workaround 1: Sequential Transaction Queuing

The cleanest solution for most applications is a transaction queue: enqueue operations and process one at a time.

class TransactionQueue {
  private queue: Array<() => Promise<void>> = [];
  private running = false;

  enqueue(task: () => Promise<void>): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      this.queue.push(async () => {
        try { await task(); resolve(); }
        catch (err) { reject(err); }
      });
      this.drain();
    });
  }

  private async drain(): Promise<void> {
    if (this.running) return;
    this.running = true;
    while (this.queue.length > 0) {
      const task = this.queue.shift()!;
      await task();
    }
    this.running = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the queue in your dApp:

const txQueue = new TransactionQueue();

async function incrementCounter(contract: CounterContract) {
  await txQueue.enqueue(async () => {
    const tx = await contract.callTx.increment();
    await tx.submit();
  });
}

// These execute one after another, never concurrently
await Promise.all([
  incrementCounter(myContract),
  incrementCounter(myContract),
  incrementCounter(myContract),
]);
Enter fullscreen mode Exit fullscreen mode

Add automatic retry for robustness:

async function submitWithRetry(
  task: () => Promise<void>,
  maxRetries = 3,
  delayMs = 2000
): Promise<void> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await task();
      return;
    } catch (err: any) {
      const isStale =
        err?.kind === 'stale_input' ||
        err?.message?.includes('stale') ||
        err?.message?.includes('input_spent');
      if (isStale && attempt < maxRetries) {
        await new Promise(r => setTimeout(r, delayMs * attempt));
      } else {
        throw err;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When to use: Single user multiple rapid ops, backend services, when ordering matters. Trade-off: ~10–30s per confirmation on testnet.


Workaround 2: Multiple Wallet Instances for Parallelism

For high-throughput backends, maintain multiple wallet instances, each with its own disjoint UTXO set.

import { WalletBuilder } from '@midnight-ntwrk/midnight-js-wallet';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';

async function createWallet(seed: string) {
  const wallet = await WalletBuilder.buildFromSeed(
    seed,
    new NodeZkConfigProvider('https://prover.testnet.midnight.network'),
    indexerPublicDataProvider('https://indexer.testnet.midnight.network/api/v1/graphql'),
    'https://rpc.testnet.midnight.network',
    'testnet'
  );
  await wallet.start();
  return wallet;
}

class WalletPool {
  private locks: boolean[];
  constructor(private wallets: any[]) {
    this.locks = new Array(wallets.length).fill(false);
  }

  async acquire() {
    while (true) {
      for (let i = 0; i < this.wallets.length; i++) {
        if (!this.locks[i]) {
          this.locks[i] = true;
          return { wallet: this.wallets[i], release: () => { this.locks[i] = false; } };
        }
      }
      await new Promise(r => setTimeout(r, 100));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the pool:

const pool = new WalletPool(await Promise.all(WALLET_SEEDS.map(createWallet)));

async function parallelMint(tokenId: string): Promise<void> {
  const { wallet, release } = await pool.acquire();
  try {
    const contract = await MyTokenContract.deploy(wallet, tokenId);
    await contract.callTx.mint().submit();
  } finally {
    release();
  }
}

// True parallel — each uses a different wallet and different UTXOs
await Promise.all([
  parallelMint('token-1'),
  parallelMint('token-2'),
  parallelMint('token-3'),
  parallelMint('token-4'),
]);
Enter fullscreen mode Exit fullscreen mode

When to use: High-throughput backend services, independent operations. Trade-off: must keep each wallet funded.


Choosing the Right Pattern

Start with the sequential queue—it handles 95% of use cases. Only reach for the wallet pool when you've measured that queue throughput is an actual bottleneck.


Summary

  • Midnight uses a UTXO model: your balance is a collection of discrete coin bundles, not a single number
  • Concurrent transactions from the same wallet fail because they compete to spend the same UTXOs
  • The stale UTXO error means a referenced UTXO was already consumed by another transaction
  • Sequential queuing is the simplest fix: process one transaction at a time and wait for confirmation
  • Multiple wallet instances enable parallelism by giving each transaction stream its own disjoint UTXO set

Questions? Join the Midnight Developer Forum at forum.midnight.network or Discord at discord.gg/midnightnetwork.

Top comments (0)