turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

FP-007

A payout queue that survives an API that says nothing

Auto-pay when the API answers, hold when it does not, and never refund on a timeout. The three-state queue that keeps a faucet solvent.

Payout APIs give three answers: yes, no, and silence. Yes and no are easy. Silence — a timeout, a dropped connection, a gateway error — is where money gets lost, because the request may well have succeeded on the other end.

The rule that follows from that: a refusal is final and can be refunded, a silence is unknown and must be held. Code that treats both as failure will refund payouts that were actually sent, and users will notice before you do.

Holding costs a little support effort. Refunding a completed payout costs the payout.

PHP
function process_payout(PDO $pdo, array $withdrawal): string
{
    try {
        $res = faucetpay_send(
            setting('fp_api_key'),
            $withdrawal['address'],
            $withdrawal['currency'],
            (int) $withdrawal['units'],
            'wd-' . $withdrawal['id']              // our reference, for reconciliation
        );
    } catch (RuntimeException $e) {
        // Silence. It may have gone through. Hold it for a human.
        $pdo->prepare('UPDATE withdrawals SET status = "held", note = ?, attempts = attempts + 1 WHERE id = ?')
            ->execute([substr($e->getMessage(), 0, 190), $withdrawal['id']]);
        return 'held';
    }

    if ((int) ($res['status'] ?? 0) === 200) {
        $pdo->prepare('UPDATE withdrawals SET status = "paid", txid = ?, paid_at = NOW() WHERE id = ? AND status <> "paid"')
            ->execute([(string) ($res['payout_id'] ?? ''), $withdrawal['id']]);
        return 'paid';
    }

    // A definite refusal: safe to return the funds.
    $pdo->beginTransaction();
    try {
        $pdo->prepare('UPDATE withdrawals SET status = "rejected", note = ? WHERE id = ? AND status = "held"')
            ->execute([substr((string) ($res['message'] ?? 'refused'), 0, 190), $withdrawal['id']]);
        $pdo->prepare('UPDATE users SET balance = balance + ? WHERE id = ?')
            ->execute([(int) $withdrawal['units'], (int) $withdrawal['user_id']]);
        $pdo->commit();
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
    return 'rejected';
}

Using it

Cap attempts. A withdrawal that has been held three times needs a person, not a fourth automatic try.

Show held withdrawals in admin with the error text and a Paid / Refund pair of buttons, so clearing them is two clicks and not a database query.

What bites people

Never refund inside the catch block. That is the one line that turns an outage into a loss.

Pass your own reference on every send. When you have to ask whether a held payout actually went out, that reference is how you find it.

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 FaucetPay