turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

FP-010

Convert amounts for FaucetPay v2 without floats

v2 send takes integer smallest units and FaucetPay uses eight decimals for every coin. Doing that conversion in floating point is how a payout comes out wrong.

A coin amount is a decimal string in your interface and an integer count of the smallest unit in the API. The conversion between them is where money gets lost, because the obvious implementation multiplies by 1e8 in floating point and relies on rounding to come out right.

String arithmetic avoids the question entirely: split on the decimal point, pad the fraction to eight places, concatenate. It is exact for every value, including the ones that a double cannot represent.

Validate while you are there. An amount with more than eight decimals is a programmer error upstream, and silently truncating it hides a real bug.

PHP
const FP_DECIMALS = 8;                           // every coin, per FaucetPay

/** '0.00000150' -> 150 ; '1' -> 100000000. Exact, no float maths. */
function fp_to_units(string|int|float $amount): int
{
    $s = is_float($amount) ? number_format($amount, FP_DECIMALS, '.', '') : trim((string) $amount);

    if (!preg_match('/^\d+(\.\d+)?$/', $s)) {
        throw new InvalidArgumentException('Invalid amount: ' . $s);
    }
    [$whole, $frac] = array_pad(explode('.', $s, 2), 2, '');
    if (strlen(rtrim($frac, '0')) > FP_DECIMALS) {
        throw new InvalidArgumentException('More than ' . FP_DECIMALS . ' decimals: ' . $s);
    }
    $frac = str_pad(substr($frac, 0, FP_DECIMALS), FP_DECIMALS, '0');

    return (int) ltrim($whole . $frac, '0');
}

/** 150 -> '0.00000150'. The inverse, for display. */
function fp_from_units(int $units): string
{
    $neg = $units < 0;
    $s = str_pad((string) abs($units), FP_DECIMALS + 1, '0', STR_PAD_LEFT);

    return ($neg ? '-' : '') . substr($s, 0, -FP_DECIMALS) . '.' . substr($s, -FP_DECIMALS);
}

Using it

Keep units as the only form in your database. Convert to a string at the edges — the form and the display — and nowhere else.

Throw on a malformed amount rather than returning zero. A zero payout looks like success and is impossible to trace later.

If you price in USD, convert to units at a rate you store on the row. The rate at the time is part of the record.

What bites people

(int) (0.00000150 * 1e8) is 149 on some inputs. That is the entire reason this function exists.

Do not assume a coin with six decimals elsewhere has six here. FaucetPay normalises everything to eight.

ltrim($whole . $frac, '0') on a zero amount yields an empty string that casts to 0 — which is correct, but a zero amount should have been rejected before this point.

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