turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

FRD-014

Gate withdrawals on verified ad revenue

Let users earn freely and withdraw only what their verified ad activity actually paid for. Entitlement is spent at request time so pending requests cannot double-spend it.

A faucet pays from ad revenue, and the mismatch between what a user earns and what they generated is where the money goes. An entitlement ledger closes it without making the site unpleasant: earn whatever the game gives you, but withdraw only up to what your verified ad work actually brought in.

Entitlement accrues on verified events — a completed shortlink, a watched rewarded ad, an offer postback — at a share of the revenue those produced. It is spent when a withdrawal is requested, not when it is paid, so two pending requests cannot both draw on the same entitlement.

Rejections and failures restore it with the credits. And anything the user won rather than earned — a jackpot — should carry its own entitlement, or a big win becomes permanently unwithdrawable and you have made a liar of your own interface.

PHP
function grant_entitlement(PDO $pdo, int $userId, int $revenueUnits, string $source, string $ref): void
{
    $share = (int) floor($revenueUnits * setting_int('entitlement_share_pct', 60) / 100);
    if ($share < 1) {
        return;
    }
    $pdo->prepare('UPDATE users SET entitlement = entitlement + ? WHERE id = ?')->execute([$share, $userId]);
    $pdo->prepare(
        'INSERT INTO entitlement_ledger (user_id, units, source, ref, created_at) VALUES (?, ?, ?, ?, NOW())'
    )->execute([$userId, $share, $source, $ref]);
}

function request_withdrawal(PDO $pdo, int $userId, int $units): array
{
    if (!setting_bool('entitlement_gate', false)) {
        return plain_withdrawal($pdo, $userId, $units);
    }

    $pdo->beginTransaction();
    try {
        $st = $pdo->prepare('SELECT balance, entitlement FROM users WHERE id = ? FOR UPDATE');
        $st->execute([$userId]);
        $u = $st->fetch(PDO::FETCH_ASSOC);

        if ((int) $u['balance'] < $units) {
            $pdo->rollBack();
            return ['ok' => false, 'why' => 'balance'];
        }
        if ((int) $u['entitlement'] < $units) {
            $pdo->rollBack();
            return [
                'ok' => false,
                'why' => 'entitlement',
                'available' => (int) $u['entitlement'],
                // Tell them exactly what unlocks it.
                'tasks_needed' => (int) ceil(($units - (int) $u['entitlement']) / max(1, setting_int('entitlement_per_task', 1))),
            ];
        }

        // Spend BOTH at request time; a second pending request sees neither.
        $pdo->prepare('UPDATE users SET balance = balance - ?, entitlement = entitlement - ? WHERE id = ?')
            ->execute([$units, $units, $userId]);
        $pdo->prepare('INSERT INTO withdrawals (user_id, units, status, created_at) VALUES (?, ?, "held", NOW())')
            ->execute([$userId, $units]);

        $pdo->commit();
        return ['ok' => true];
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

Using it

Show the whole picture on the withdraw page: balance, available now, verified tasks, what is locked, and how many tasks would unlock the rest. A locked balance with no explanation reads as theft.

Grandfather existing balances when you turn the gate on, or every current user is locked out on the day you deploy it.

Restore entitlement alongside credits on a rejection or a failed payout, in the same transaction.

What bites people

Check the minimum and the entitlement before rendering the form. A form with min above max cannot be submitted and looks broken.

Jackpots and prizes need their own entitlement grant or they are unwithdrawable forever.

Default the gate off. It is a significant change in what users can do, and it should be a deliberate switch.

This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.

Also in Anti-Bot and Fraud