path($index)); return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); } public function sendNative(string $mnemonic, int $index, string $to, string $amount): string { if (! $this->isValidAddress($to)) { throw new RuntimeException('Invalid Tron address'); } $derived = Bip44::derive($mnemonic, $this->path($index)); $from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); return $this->sendNativeWithPrivateKey($derived['private_key'], $from, $to, $amount); } /** * Send TRX using a raw private key (fee top-up wallet). */ public function sendNativeWithPrivateKey(string $privateKeyHex, string $from, string $to, string $amount): string { if (! $this->isValidAddress($from) || ! $this->isValidAddress($to)) { throw new RuntimeException('Invalid Tron address'); } $sun = $this->toSun($amount); $tx = $this->post('/wallet/createtransaction', [ 'owner_address' => $from, 'to_address' => $to, 'amount' => (int) $sun, 'visible' => true, ]); return $this->signAndBroadcast($tx, $privateKeyHex); } public static function addressFromPrivateKey(string $privateKeyHex): string { $privateKeyHex = strtolower(trim($privateKeyHex)); if (str_starts_with($privateKeyHex, '0x')) { $privateKeyHex = substr($privateKeyHex, 2); } $ec = new \Elliptic\EC('secp256k1'); $pub = $ec->keyFromPrivate($privateKeyHex)->getPublic(false, 'hex'); return TronAddress::fromUncompressedPublicKey($pub); } public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string { if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) { throw new RuntimeException('Invalid Tron address'); } $derived = Bip44::derive($mnemonic, $this->path($index)); $from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); $sun = $this->toSun($amount); $parameter = $this->encodeTransferParameter($to, $sun); $ext = $this->post('/wallet/triggersmartcontract', [ 'owner_address' => $from, 'contract_address' => $contract, 'function_selector' => 'transfer(address,uint256)', 'parameter' => $parameter, 'fee_limit' => (int) config('coruna.tron.fee_limit', 100_000_000), 'call_value' => 0, 'visible' => true, ]); $tx = $ext['transaction'] ?? null; if (! is_array($tx)) { $msg = $ext['result']['message'] ?? ($ext['message'] ?? 'triggersmartcontract failed'); throw new RuntimeException(is_string($msg) ? $msg : 'triggersmartcontract failed'); } return $this->signAndBroadcast($tx, $derived['private_key']); } public function isValidAddress(string $address): bool { return TronAddress::isValid($address); } /** * Full-node getaccount. Never-activated addresses come back as {}. * * @return array */ public function fetchAccount(string $address): array { if (! $this->isValidAddress($address)) { throw new RuntimeException('Invalid Tron address'); } return $this->post('/wallet/getaccount', [ 'address' => $address, 'visible' => true, ]); } /** * @param array $account */ public static function accountIsActivated(array $account): bool { return isset($account['address']) || isset($account['create_time']); } /** * One getaccount: activation + TRX. Unactivated accounts have no on-chain state. * * @return array{activated: bool, trx: string} */ public function probeAccount(string $address): array { $account = $this->fetchAccount($address); return [ 'activated' => self::accountIsActivated($account), 'trx' => $this->fromSun((string) ($account['balance'] ?? 0)), ]; } public function getNativeBalance(string $address): string { return $this->probeAccount($address)['trx']; } public function getTokenBalance(string $address, string $contract): string { if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) { throw new RuntimeException('Invalid Tron address'); } $parameter = str_pad(TronAddress::toHex($address), 64, '0', STR_PAD_LEFT); $ext = $this->post('/wallet/triggerconstantcontract', [ 'owner_address' => $address, 'contract_address' => $contract, 'function_selector' => 'balanceOf(address)', 'parameter' => $parameter, 'visible' => true, ]); $hex = $ext['constant_result'][0] ?? null; if (! is_string($hex) || $hex === '') { return '0'; } $sun = gmp_strval(gmp_init($hex, 16), 10); return $this->fromSun($sun); } private function path(int $index): string { return "m/44'/195'/0'/0/{$index}"; } private function toSun(string $amount): string { if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount)) { throw new RuntimeException('Invalid amount'); } $sun = bcmul($amount, '1000000', 0); if (bccomp($sun, '0') <= 0) { throw new RuntimeException('Amount must be positive'); } return $sun; } private function fromSun(string $sun): string { if (! preg_match('/^\d+$/', $sun)) { $sun = '0'; } $human = bcdiv($sun, '1000000', 6); $human = rtrim(rtrim($human, '0'), '.'); return $human === '' ? '0' : $human; } private function encodeTransferParameter(string $toBase58, string $amountSun): string { $toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes $addressWord = str_pad($toHex, 64, '0', STR_PAD_LEFT); $amountWord = str_pad(gmp_strval(gmp_init($amountSun, 10), 16), 64, '0', STR_PAD_LEFT); return $addressWord.$amountWord; } /** * @param array $tx */ private function signAndBroadcast(array $tx, string $privateKeyHex): string { $txId = $tx['txID'] ?? null; if (! is_string($txId) || $txId === '') { create_log([ 'event' => 'tron_sign_failed', 'error' => 'Missing txID from node', 'tx_keys' => array_keys($tx), ], 'transfer'); throw new RuntimeException('Missing txID from node'); } create_log([ 'event' => 'tron_sign_start', 'txid' => $txId, 'txid_len' => strlen($txId), 'txid_prefix' => substr($txId, 0, 8), ], 'transfer'); try { $signature = TronSigner::signTxId($privateKeyHex, $txId); } catch (\Throwable $e) { create_log([ 'event' => 'tron_sign_failed', 'txid' => $txId, 'txid_len' => strlen($txId), 'txid_prefix' => substr($txId, 0, 8), 'error' => $e->getMessage(), ], 'transfer'); throw $e; } $tx['signature'] = [$signature]; $result = $this->post('/wallet/broadcasttransaction', $tx); $ok = ($result['result'] ?? false) === true; if (! $ok) { $msg = $result['message'] ?? ($result['code'] ?? 'broadcast failed'); if (is_string($msg) && ctype_xdigit($msg)) { $decoded = @hex2bin($msg); $msg = $decoded !== false ? $decoded : $msg; } $error = is_string($msg) ? $msg : 'broadcast failed'; create_log([ 'event' => 'tron_broadcast_failed', 'txid' => $txId, 'txid_len' => strlen($txId), 'error' => $error, 'result' => $result, ], 'transfer'); throw new RuntimeException($error); } create_log([ 'event' => 'tron_broadcast_ok', 'txid' => $txId, ], 'transfer'); return $txId; } /** * @param array $payload * @return array */ private function post(string $path, array $payload): array { $resp = $this->http()->post(rtrim((string) config('coruna.tron.full_node'), '/').$path, $payload); if (! $resp->successful()) { throw new RuntimeException('Tron node HTTP '.$resp->status()); } $json = $resp->json(); if (! is_array($json)) { throw new RuntimeException('Invalid Tron node response'); } return $json; } private function http(): PendingRequest { $req = Http::timeout(30)->acceptJson()->asJson(); $apiKey = (string) config('coruna.tron.api_key', ''); if ($apiKey !== '') { $req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]); } return $req; } }