FCT-001
A claim cooldown the client cannot argue with
Reward the claim and start the next cooldown in one transaction, so a double-submitted form pays once instead of twice.
The countdown on the page is decoration. The only cooldown that matters is the one your database enforces, and the only way to enforce it under concurrency is to lock the row before you read the last claim time.
Without the lock, two requests arriving together both read the same "last claimed" value, both decide the cooldown has passed, and both pay. Users find this within a day of launch, usually by accident, and then not by accident.
The transaction below reads with FOR UPDATE, checks, credits and stamps the new time as one unit. A second request waits for the first to finish and then correctly sees the cooldown as unexpired.
function claim(PDO $pdo, int $userId, int $rewardUnits, int $cooldownSeconds): array
{
$pdo->beginTransaction();
try {
$st = $pdo->prepare(
'SELECT balance, last_claim_at,
TIMESTAMPDIFF(SECOND, last_claim_at, NOW()) AS age
FROM users WHERE id = ? FOR UPDATE'
);
$st->execute([$userId]);
$u = $st->fetch(PDO::FETCH_ASSOC);
if (!$u) {
$pdo->rollBack();
return ['ok' => false, 'reason' => 'no such user'];
}
$age = $u['last_claim_at'] === null ? PHP_INT_MAX : (int) $u['age'];
if ($age < $cooldownSeconds) {
$pdo->rollBack();
return ['ok' => false, 'reason' => 'cooldown', 'wait' => $cooldownSeconds - $age];
}
$pdo->prepare('UPDATE users SET balance = balance + ?, last_claim_at = NOW() WHERE id = ?')
->execute([$rewardUnits, $userId]);
$pdo->prepare('INSERT INTO ledger (user_id, kind, units, created_at) VALUES (?, "claim", ?, NOW())')
->execute([$userId, $rewardUnits]);
$pdo->commit();
return ['ok' => true, 'credited' => $rewardUnits, 'next_in' => $cooldownSeconds];
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
Using it
Keep balances as integers in the coin's smallest unit. Floats will cost you the difference eventually and the ledger will not add up.
Return the seconds remaining on a refused claim so the page can restart its timer from the server's answer rather than its own guess.
What bites people
FOR UPDATE only locks inside a transaction, and only on InnoDB. On MyISAM this code silently does nothing useful.
Do not compute the cooldown in PHP from a timestamp you read earlier in the request. Between the read and the write is exactly where the double claim lives.
Rolling back on a refused claim matters: without it, the transaction stays open and holds the lock until the script ends.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.