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.
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.