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();
}
}
}
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;
}
}
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));
}
}
}
Key Takeaways
- Sequential queue for most dApps — simple, reliable
- Wallet pool for high-throughput services
- Retry + sync as safety net for all cases
- Always sync wallet between transactions
Full tutorial: https://gist.github.com/wilsonhoe/a5766903b8c99f9df222ea322e63418b
Midnight Network tutorial series. Bounty #301.
Top comments (0)