244 lines
7.8 KiB
PHP
244 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Chain;
|
|
|
|
use Elliptic\EC;
|
|
use RuntimeException;
|
|
|
|
final class BtcAddress
|
|
{
|
|
public static function fromPrivateKey(string $privateKeyHex): string
|
|
{
|
|
$privateKeyHex = strtolower(trim($privateKeyHex));
|
|
if (str_starts_with($privateKeyHex, '0x')) {
|
|
$privateKeyHex = substr($privateKeyHex, 2);
|
|
}
|
|
$ec = new EC('secp256k1');
|
|
$compressed = $ec->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));
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
/** @param list<int> $values */
|
|
private static function bech32Verify(string $hrp, array $values): bool
|
|
{
|
|
return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1;
|
|
}
|
|
|
|
/** @return list<int> */
|
|
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<int> $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<int> $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;
|
|
}
|
|
}
|