PAY-006
Send an ERC-20 or BEP-20 token with raw JSON-RPC
Build the transfer calldata, estimate the gas, sign, and broadcast — plus the nonce management that decides whether two payouts collide.
Token transfers are a contract call, not a value transfer. The data field carries the function selector for transfer(address,uint256) followed by the recipient and the amount, each padded to 32 bytes. Get the padding wrong and the transaction succeeds while sending nothing.
Decimals are per token, not per chain. USDT on Ethereum has six, most others have eighteen. Assuming eighteen for a six-decimal token overpays by a factor of a trillion.
The operational problem is the nonce. Two payouts built from the same nonce means one replaces the other, and the one that lost looks sent from your side and never arrives.
function erc20_transfer_data(string $to, string $amountHex): string
{
// keccak256("transfer(address,uint256)") first 4 bytes
$selector = 'a9059cbb';
$addr = str_pad(strtolower(ltrim($to, '0x')), 64, '0', STR_PAD_LEFT);
$amt = str_pad(ltrim($amountHex, '0x'), 64, '0', STR_PAD_LEFT);
return '0x' . $selector . $addr . $amt;
}
function token_units(string $decimalAmount, int $decimals): string
{
// bcmath keeps the precision that floats destroy at 18 decimals.
$units = bcmul($decimalAmount, bcpow('10', (string) $decimals), 0);
return '0x' . strtoupper(base_convert_big($units)); // decimal string -> hex
}
function next_nonce(PDO $pdo, string $from, array $rpc): int
{
// Take the chain's pending count, but never below what we have already used:
// two payouts in the same second must not build the same nonce.
$chain = hexdec(rpc_eth($rpc, 'eth_getTransactionCount', [$from, 'pending']));
$pdo->beginTransaction();
$st = $pdo->prepare('SELECT last_nonce FROM evm_wallet WHERE address = ? FOR UPDATE');
$st->execute([$from]);
$ours = (int) $st->fetchColumn();
$nonce = max((int) $chain, $ours + 1);
$pdo->prepare('UPDATE evm_wallet SET last_nonce = ? WHERE address = ?')->execute([$nonce, $from]);
$pdo->commit();
return $nonce;
}
Using it
Read the decimals from the contract once and cache them per token. Do not hardcode eighteen.
Estimate gas with eth_estimateGas and add a margin. A transfer to an address that has never held the token costs more than one to an address that has.
Keep enough native coin for gas. A token balance with no ETH or BNB cannot move.
What bites people
bcmath or gmp, never floats. An 18-decimal amount does not survive a double.
A stuck low-gas transaction blocks every later nonce. Have a way to resend the same nonce at a higher price.
transfer returning false does not revert on some older tokens. Check the receipt status rather than assuming success from a broadcast.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.