Description
In app/app/api/wallet/withdraw/route.ts (on-chain path), the balance check and the decrement are separated by an un-held on-chain submission:
if (user.walletBalance < amount) return apiError("Insufficient wallet balance", 400);
// ... submitStellarWithdrawal(...) — network call, no balance hold ...
prisma.user.update({ data: { walletBalance: { decrement: amount } } });
Two concurrent withdrawal requests can both pass the < amount check, both submit a payment to Stellar, and both decrement afterward — driving walletBalance negative and disbursing more than the user holds. The decrement also lacks a walletBalance: { gte: amount } guard, so it cannot fail safely. (The simulated path is correctly atomic; only the on-chain path is vulnerable.)
More info
- File:
app/app/api/wallet/withdraw/route.ts (approx. lines 46-99)
- Reserve the balance atomically before submitting:
updateMany({ where: { id, walletBalance: { gte: amount } }, data: { decrement: { walletBalance: amount } } }) and bail if count === 0.
- Refund the reserved amount if
submitStellarWithdrawal fails.
- Add a concurrency test issuing two simultaneous withdrawals that together exceed the balance and assert only one succeeds.
Description
In
app/app/api/wallet/withdraw/route.ts(on-chain path), the balance check and the decrement are separated by an un-held on-chain submission:Two concurrent withdrawal requests can both pass the
< amountcheck, both submit a payment to Stellar, and both decrement afterward — drivingwalletBalancenegative and disbursing more than the user holds. The decrement also lacks awalletBalance: { gte: amount }guard, so it cannot fail safely. (Thesimulatedpath is correctly atomic; only the on-chain path is vulnerable.)More info
app/app/api/wallet/withdraw/route.ts(approx. lines 46-99)updateMany({ where: { id, walletBalance: { gte: amount } }, data: { decrement: { walletBalance: amount } } })and bail ifcount === 0.submitStellarWithdrawalfails.