LDG-002
A withdrawal that cannot be paid twice
Debit on request, not on approval. The state machine that makes double payouts and phantom balances impossible.
The intuitive design is to let a user request a withdrawal, leave their balance alone, and debit when an admin approves it. It is wrong in a way that costs money: between request and approval the user can spend or withdraw the same balance again.
Debit on request instead. The funds move out of the spendable balance the moment the request exists and sit in a held state. Approval turns a held amount into a paid one and touches no balance; rejection returns it. There is no window in which the same money is both requested and available.
The check and the debit have to happen in the same transaction with the balance row locked, or two simultaneous requests both pass a check that only one should.
function request_withdrawal(PDO $pdo, int $userId, int $units, string $address): int
{
if ($units <= 0) {
throw new InvalidArgumentException('bad amount');
}
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT balance FROM users WHERE id = ? FOR UPDATE');
$st->execute([$userId]);
$balance = (int) $st->fetchColumn();
if ($balance < $units) {
$pdo->rollBack();
throw new RuntimeException('insufficient balance');
}
// Debit NOW. The held amount is no longer spendable anywhere else.
$pdo->prepare('UPDATE users SET balance = balance - ? WHERE id = ?')->execute([$units, $userId]);
$pdo->prepare(
'INSERT INTO withdrawals (user_id, units, address, status, created_at)
VALUES (?, ?, ?, "held", NOW())'
)->execute([$userId, $units, $address]);
$id = (int) $pdo->lastInsertId();
$pdo->commit();
return $id;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function mark_paid(PDO $pdo, int $withdrawalId, string $txid): bool
{
// Only a held row can become paid. A second call updates nothing.
$st = $pdo->prepare(
'UPDATE withdrawals SET status = "paid", txid = ?, paid_at = NOW()
WHERE id = ? AND status = "held"'
);
$st->execute([$txid, $withdrawalId]);
return $st->rowCount() === 1; // false = already handled
}
function reject_withdrawal(PDO $pdo, int $withdrawalId): bool
{
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT user_id, units FROM withdrawals WHERE id = ? AND status = "held" FOR UPDATE');
$st->execute([$withdrawalId]);
$w = $st->fetch(PDO::FETCH_ASSOC);
if (!$w) {
$pdo->rollBack();
return false;
}
$pdo->prepare('UPDATE withdrawals SET status = "rejected" WHERE id = ?')->execute([$withdrawalId]);
$pdo->prepare('UPDATE users SET balance = balance + ? WHERE id = ?')->execute([$w['units'], $w['user_id']]);
$pdo->commit();
return true;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
Using it
The status condition in the UPDATE is the double-pay guard. Because it returns a row count, an admin double-clicking Pay gets a clean false rather than a second payment.
When an automatic payout API fails, leave the row held and fall back to the manual queue. Held funds are recoverable; released ones are not.
Sum held withdrawals separately in your reconciliation. Balance plus held should equal what the ledger says you owe.
What bites people
Never refund on a transport error. A timeout means unknown, not failed, and refunding an unknown is how the same withdrawal gets paid on chain and returned to the balance.
Show the user the held amount as pending rather than deducting it silently. The balance dropping with no explanation generates more support mail than the withdrawal itself.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.