turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

PAY-004

Send coin from your own node over JSON-RPC

sendtoaddress against bitcoind, litecoind or dogecoind, with the wallet passphrase handling and the error codes that matter.

Running your own node removes the processor from the middle: no fees beyond the network's, no account to be frozen, no API to be deprecated. The cost is that you now operate a wallet, and mistakes are irreversible.

The interface is JSON-RPC over HTTP with basic auth. sendtoaddress takes an address and an amount in whole coins as a string — not satoshi, which is the opposite of every faucet API and a good way to send a hundred million times too much.

Encrypted wallets need unlocking for a short window before each send. Unlock for seconds, not minutes, and lock again immediately.

PHP
function rpc(array $cfg, string $method, array $params = [])
{
    $payload = json_encode(['jsonrpc' => '1.0', 'id' => 'php', 'method' => $method, 'params' => $params]);

    $ch = curl_init($cfg['url']);                       // http://127.0.0.1:8332/
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_USERPWD        => $cfg['user'] . ':' . $cfg['pass'],
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
    ]);
    $body = curl_exec($ch);
    curl_close($ch);

    if ($body === false) {
        throw new RuntimeException('node unreachable');  // unknown: never refund on this
    }
    $res = json_decode((string) $body, true);
    if (isset($res['error']) && $res['error'] !== null) {
        throw new RuntimeException('rpc error ' . ($res['error']['code'] ?? '?') . ': ' . ($res['error']['message'] ?? ''));
    }
    return $res['result'] ?? null;
}

function node_send(array $cfg, string $address, float $coins, string $comment = ''): string
{
    // Amount is in WHOLE COINS as a string, not satoshi.
    $amount = number_format($coins, 8, '.', '');

    if (!empty($cfg['passphrase'])) {
        rpc($cfg, 'walletpassphrase', [$cfg['passphrase'], 10]);   // 10 seconds, not longer
    }
    try {
        return (string) rpc($cfg, 'sendtoaddress', [$address, $amount, $comment]);
    } finally {
        if (!empty($cfg['passphrase'])) {
            rpc($cfg, 'walletlock');
        }
    }
}

Using it

Bind the RPC port to localhost and nothing else. An exposed RPC port with weak credentials is a wallet someone else can spend.

Check getbalance with a minimum confirmation count before sending, and keep a reserve for fees.

Store the returned txid on the payout row. On chain it is the only proof the payment happened.

What bites people

Error code -6 is insufficient funds, -5 is an invalid address, -13 means the wallet is locked. Each needs a different response, and treating them all as generic failure will refund payouts that never went out.

A timeout is not a failure. The transaction may have broadcast. Hold it and check by txid rather than retrying blind.

Amounts are strings. Passing a float lets PHP's default precision drop digits on small values.

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 Payment Rails