path($index)); return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); } public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string { if (! $this->isValidAddress($to)) { throw new RuntimeException('Invalid ETH address'); } $derived = Bip44::derive($mnemonic, $this->path($index)); $from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); $wei = $this->toWei($amount); return $this->sendLegacy($derived['private_key'], $from, $to, $wei, '0x'); } 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 ETH address'); } $derived = Bip44::derive($mnemonic, $this->path($index)); $from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); $units = $this->toTokenUnits($amount, $this->tokenDecimals($contract)); // transfer(address,uint256) selector = a9059cbb $data = '0xa9059cbb'.EthAddress::toWord($to).str_pad(gmp_strval(gmp_init($units, 10), 16), 64, '0', STR_PAD_LEFT); return $this->sendLegacy($derived['private_key'], $from, $contract, '0', $data); } public function isValidAddress(string $address): bool { return EthAddress::isValid($address); } public function isActivated(string $address): bool { if (! $this->isValidAddress($address)) { return false; } try { $hex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($address), 'latest']); if (is_string($hex) && bccomp($this->hexToDec($hex), '0', 0) > 0) { return true; } } catch (\Throwable $e) { if (ChainHttpTimeout::active()) { throw $e; } // fall through to balances } try { if (bccomp($this->getNativeBalance($address), '0', 18) > 0) { return true; } } catch (\Throwable $e) { if (ChainHttpTimeout::active()) { throw $e; } // fall through to token } $contract = trim((string) config($this->configPrefix().'.usdt_contract', '')); if ($contract !== '') { try { if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) { return true; } } catch (\Throwable $e) { if (ChainHttpTimeout::active()) { throw $e; } return false; } } return false; } public function getNativeBalance(string $address): string { if (! $this->isValidAddress($address)) { throw new RuntimeException('Invalid ETH address'); } $hex = $this->rpc('eth_getBalance', [EthAddress::normalize($address), 'latest']); if (! is_string($hex)) { return '0'; } return $this->fromWei($this->hexToDec($hex)); } public function getTokenBalance(string $address, string $contract): string { if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) { throw new RuntimeException('Invalid ETH address'); } // balanceOf(address) $data = '0x70a08231'.EthAddress::toWord($address); $hex = $this->rpc('eth_call', [[ 'to' => EthAddress::normalize($contract), 'data' => $data, ], 'latest']); if (! is_string($hex) || $hex === '' || $hex === '0x') { return '0'; } return $this->fromTokenUnits($this->hexToDec($hex), $this->tokenDecimals($contract)); } private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string { $chainId = (int) config($this->configPrefix().'.chain_id', 1); $nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']); $gasPriceHex = $this->rpc('eth_gasPrice', []); if (! is_string($nonceHex) || ! is_string($gasPriceHex)) { throw new RuntimeException('Failed to fetch nonce/gasPrice'); } $gasLimit = trim((string) config($this->configPrefix().'.gas_limit', '')); if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) { $gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000'; } $tx = [ 'nonce' => $this->hexToDec($nonceHex), 'gasPrice' => $this->hexToDec($gasPriceHex), 'gas' => $gasLimit, 'to' => EthAddress::normalize($to), 'value' => $valueWei, 'data' => $data === '' ? '0x' : $data, ]; $raw = EthSigner::signLegacy($privateKey, $tx, $chainId); $txid = $this->rpc('eth_sendRawTransaction', [$raw]); if (! is_string($txid) || $txid === '') { throw new RuntimeException('eth_sendRawTransaction failed'); } return $txid; } private function path(int $index): string { return "m/44'/60'/0'/0/{$index}"; } private function toWei(string $amount): string { if (! preg_match('/^\d+(\.\d{1,18})?$/', $amount)) { throw new RuntimeException('Invalid ETH amount'); } [$whole, $frac] = array_pad(explode('.', $amount, 2), 2, ''); $frac = str_pad(substr($frac, 0, 18), 18, '0', STR_PAD_RIGHT); $wei = ltrim($whole.$frac, '0'); $wei = $wei === '' ? '0' : $wei; if (bccomp($wei, '0') <= 0) { throw new RuntimeException('Amount must be positive'); } return $wei; } private function fromWei(string $wei): string { if (! preg_match('/^\d+$/', $wei)) { $wei = '0'; } $human = bcdiv($wei, '1000000000000000000', 18); $human = rtrim(rtrim($human, '0'), '.'); return $human === '' ? '0' : $human; } protected function configPrefix(): string { return 'coruna.eth'; } private function tokenDecimals(string $contract): int { $normalized = EthAddress::normalize($contract); $usdt = EthAddress::normalize((string) config($this->configPrefix().'.usdt_contract', '')); $decimals = (int) config($this->configPrefix().'.usdt_decimals', 6); if ($normalized !== '' && $usdt !== '' && $normalized === $usdt && $decimals > 0) { return $decimals; } return max(1, $decimals > 0 ? $decimals : 6); } private function toTokenUnits(string $amount, int $decimals): string { $decimals = max(1, $decimals); if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) { throw new RuntimeException('Invalid token amount'); } $factor = bcpow('10', (string) $decimals, 0); $units = bcmul($amount, $factor, 0); if (bccomp($units, '0') <= 0) { throw new RuntimeException('Amount must be positive'); } return $units; } private function fromTokenUnits(string $units, int $decimals): string { $decimals = max(0, $decimals); if (! preg_match('/^\d+$/', $units)) { $units = '0'; } $factor = bcpow('10', (string) $decimals, 0); $human = bcdiv($units, $factor, $decimals); $human = rtrim(rtrim($human, '0'), '.'); return $human === '' ? '0' : $human; } private function hexToDec(string $hex): string { $hex = strtolower($hex); if (str_starts_with($hex, '0x')) { $hex = substr($hex, 2); } if ($hex === '' || $hex === '0') { return '0'; } return gmp_strval(gmp_init($hex, 16), 10); } private function rpc(string $method, array $params): mixed { $url = rtrim((string) config($this->configPrefix().'.rpc_url', 'https://ethereum.publicnode.com'), '/'); $resp = $this->http()->post($url, [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params, ]); if (! $resp->successful()) { throw new RuntimeException(strtoupper($this->chainId()).' RPC HTTP '.$resp->status()); } $json = $resp->json(); if (! is_array($json)) { throw new RuntimeException('Invalid '.strtoupper($this->chainId()).' RPC response'); } if (isset($json['error'])) { $msg = $json['error']['message'] ?? json_encode($json['error']); throw new RuntimeException(strtoupper($this->chainId()).' RPC: '.(is_string($msg) ? $msg : 'error')); } return $json['result'] ?? null; } private function http(): PendingRequest { return ChainHttpTimeout::apply(Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson()); } }