keyFromPrivate($privateKeyHex)->getPublic(true, 'hex'); return self::p2pkhFromCompressedPublicKey($compressed); } public static function p2pkhFromCompressedPublicKey(string $compressedHex): string { $compressedHex = strtolower(trim($compressedHex)); $pub = hex2bin($compressedHex); if ($pub === false || (strlen($pub) !== 33)) { throw new RuntimeException('Expected compressed secp256k1 public key'); } $hash160 = hash('ripemd160', hash('sha256', $pub, true), true); return TronAddress::hexToBase58Check('00'.bin2hex($hash160)); } /** * Produce a Native SegWit (P2WPKH, bech32) address from a compressed * secp256k1 public key. Used for BIP84 derivation paths. */ public static function p2wpkhFromCompressedPublicKey(string $compressedHex): string { $compressedHex = strtolower(trim($compressedHex)); $pub = hex2bin($compressedHex); if ($pub === false || strlen($pub) !== 33) { throw new RuntimeException('Expected compressed secp256k1 public key'); } $hash160 = hash('ripemd160', hash('sha256', $pub, true), true); return self::bech32Encode('bc', 0, bin2hex($hash160)); } public static function isValid(string $address): bool { $address = trim($address); if ($address === '') { return false; } if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $address)) { try { self::decodeBech32($address); return true; } catch (\Throwable) { return false; } } if (! preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $address)) { return false; } try { $hex = TronAddress::base58CheckToHex($address); } catch (\Throwable) { return false; } $version = substr($hex, 0, 2); return ($version === '00' || $version === '05') && strlen($hex) === 42; } /** * @return array{type: string, script: string} script hex */ public static function scriptPubKey(string $address): array { $address = trim($address); if (preg_match('/^bc1/i', $address)) { $decoded = self::decodeBech32($address); $prog = $decoded['program']; $ver = $decoded['version']; if ($ver === 0 && strlen($prog) === 20) { // OP_0 <20> return ['type' => 'p2wpkh', 'script' => '0014'.bin2hex($prog)]; } if ($ver === 0 && strlen($prog) === 32) { return ['type' => 'p2wsh', 'script' => '0020'.bin2hex($prog)]; } throw new RuntimeException('Unsupported bech32 witness program'); } $hex = TronAddress::base58CheckToHex($address); $version = substr($hex, 0, 2); $hash = substr($hex, 2); if ($version === '00' && strlen($hash) === 40) { // OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG return ['type' => 'p2pkh', 'script' => '76a914'.$hash.'88ac']; } if ($version === '05' && strlen($hash) === 40) { // OP_HASH160 <20> OP_EQUAL return ['type' => 'p2sh', 'script' => 'a914'.$hash.'87']; } throw new RuntimeException('Unsupported BTC address type'); } public static function hash160Compressed(string $privateKeyHex): string { $privateKeyHex = strtolower(trim($privateKeyHex)); if (str_starts_with($privateKeyHex, '0x')) { $privateKeyHex = substr($privateKeyHex, 2); } $ec = new EC('secp256k1'); $compressed = hex2bin($ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex')); if ($compressed === false) { throw new RuntimeException('Invalid public key'); } return hash('ripemd160', hash('sha256', $compressed, true), true); } public static function compressedPublicKey(string $privateKeyHex): string { $privateKeyHex = strtolower(trim($privateKeyHex)); if (str_starts_with($privateKeyHex, '0x')) { $privateKeyHex = substr($privateKeyHex, 2); } $ec = new EC('secp256k1'); return $ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex'); } /** * @return array{version: int, program: string} */ public static function decodeBech32(string $address): array { $address = strtolower(trim($address)); $pos = strrpos($address, '1'); if ($pos === false || $pos < 1) { throw new RuntimeException('Invalid bech32'); } $hrp = substr($address, 0, $pos); if ($hrp !== 'bc' && $hrp !== 'tb') { throw new RuntimeException('Unsupported bech32 hrp'); } $dataPart = substr($address, $pos + 1); $charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; $values = []; for ($i = 0, $len = strlen($dataPart); $i < $len; $i++) { $idx = strpos($charset, $dataPart[$i]); if ($idx === false) { throw new RuntimeException('Invalid bech32 character'); } $values[] = $idx; } if (count($values) < 7) { throw new RuntimeException('Invalid bech32 length'); } if (! self::bech32Verify($hrp, $values)) { throw new RuntimeException('Invalid bech32 checksum'); } $values = array_slice($values, 0, -6); $version = $values[0]; if ($version > 16) { throw new RuntimeException('Invalid witness version'); } $program = self::convertBits(array_slice($values, 1), 5, 8, false); if ($program === null) { throw new RuntimeException('Invalid witness program'); } $len = strlen($program); if ($len < 2 || $len > 40) { throw new RuntimeException('Invalid witness program length'); } if ($version === 0 && $len !== 20 && $len !== 32) { throw new RuntimeException('Invalid v0 witness program'); } return ['version' => $version, 'program' => $program]; } /** * Encode a witness program as a bech32 address. * * @param string $hrp Human-readable part ('bc' or 'tb') * @param int $witver Witness version (0 for P2WPKH/P2WSH) * @param string $programHex Witness program as hex string */ private static function bech32Encode(string $hrp, int $witver, string $programHex): string { $charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; $bytes = array_map('hexdec', str_split($programHex, 2)); $values = [$witver]; $bits = ''; foreach ($bytes as $b) { $bits .= str_pad(decbin($b), 8, '0', STR_PAD_LEFT); } $bits = str_pad($bits, (int) ceil(strlen($bits) / 5) * 5, '0', STR_PAD_RIGHT); for ($i = 0; $i < strlen($bits); $i += 5) { $values[] = bindec(substr($bits, $i, 5)); } $values = array_merge($values, self::bech32Checksum($hrp, $values)); $result = $hrp.'1'; foreach ($values as $v) { $result .= $charset[$v]; } return $result; } /** @param list $values */ private static function bech32Checksum(string $hrp, array $values): array { $polymod = self::bech32Polymod(array_merge( self::bech32HrpExpand($hrp), $values, [0, 0, 0, 0, 0, 0], )); $polymod ^= 1; $ret = []; for ($i = 0; $i < 6; $i++) { $ret[] = ($polymod >> 5 * (5 - $i)) & 31; } return $ret; } /** @param list $values */ private static function bech32Verify(string $hrp, array $values): bool { return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1; } /** @return list */ private static function bech32HrpExpand(string $hrp): array { $ret = []; $len = strlen($hrp); for ($i = 0; $i < $len; $i++) { $ret[] = ord($hrp[$i]) >> 5; } $ret[] = 0; for ($i = 0; $i < $len; $i++) { $ret[] = ord($hrp[$i]) & 31; } return $ret; } /** @param list $values */ private static function bech32Polymod(array $values): int { $gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; $chk = 1; foreach ($values as $v) { $b = $chk >> 25; $chk = (($chk & 0x1ffffff) << 5) ^ $v; for ($i = 0; $i < 5; $i++) { if (($b >> $i) & 1) { $chk ^= $gen[$i]; } } } return $chk; } /** * @param list $data */ private static function convertBits(array $data, int $from, int $to, bool $pad): ?string { $acc = 0; $bits = 0; $ret = ''; $maxv = (1 << $to) - 1; foreach ($data as $value) { if ($value < 0 || ($value >> $from) !== 0) { return null; } $acc = ($acc << $from) | $value; $bits += $from; while ($bits >= $to) { $bits -= $to; $ret .= chr(($acc >> $bits) & $maxv); } } if ($pad) { if ($bits > 0) { $ret .= chr(($acc << ($to - $bits)) & $maxv); } } elseif ($bits >= $from || ((($acc << ($to - $bits)) & $maxv) !== 0)) { return null; } return $ret; } }