Files
coruna-lab/app/Services/Chain/BtcAddress.php
T
root 30357c108f fix(chain): support Taproot (bech32m/BIP350) in BtcAddress validation + scriptPubKey
BtcAddress::bech32Verify only checked the bech32 (BIP173) checksum
constant (=== 1), so valid Taproot addresses (bc1p, witness v1,
bech32m, const 0x2bc830a3) failed checksum verification and were
rejected as 'Invalid to address' by TransferService.

- bech32Verify now returns the detected encoding ('bech32' | 'bech32m' | null)
- decodeBech32 enforces BIP350 version<->encoding consistency
  (v0 must be bech32, v1+ must be bech32m)
- scriptPubKey adds the P2TR (v1 + 32-byte) branch: OP_1 <32> = 5120...
- bech32Checksum/bech32Encode pick the correct constant per witness
  version so Taproot encoding round-trips correctly

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 19:28:00 +00:00

333 lines
11 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));
}
/**
* 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)];
}
if ($ver === 1 && strlen($prog) === 32) {
// Taproot (BIP341): OP_1 <32>
return ['type' => 'p2tr', 'script' => '5120'.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');
}
$spec = self::bech32Verify($hrp, $values);
if ($spec === null) {
throw new RuntimeException('Invalid bech32 checksum');
}
$values = array_slice($values, 0, -6);
$version = $values[0];
if ($version > 16) {
throw new RuntimeException('Invalid witness version');
}
// BIP350: witness v0 must use bech32, v1+ must use bech32m.
if ($version === 0 && $spec !== 'bech32') {
throw new RuntimeException('Invalid bech32 checksum (v0 must be bech32)');
}
if ($version !== 0 && $spec !== 'bech32m') {
throw new RuntimeException('Invalid bech32m checksum (v1+ must be bech32m)');
}
$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, $witver));
$result = $hrp.'1';
foreach ($values as $v) {
$result .= $charset[$v];
}
return $result;
}
/** @param list<int> $values */
private static function bech32Checksum(string $hrp, array $values, int $witver): array
{
// BIP350: v0 uses bech32 const (1), v1+ uses bech32m const (0x2bc830a3).
$const = $witver === 0 ? 1 : 0x2bc830a3;
$polymod = self::bech32Polymod(array_merge(
self::bech32HrpExpand($hrp),
$values,
[0, 0, 0, 0, 0, 0],
));
$polymod ^= $const;
$ret = [];
for ($i = 0; $i < 6; $i++) {
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
}
return $ret;
}
/**
* Detect bech32/bech32m encoding from the checksum (BIP173 / BIP350).
*
* @param list<int> $values
* @return string|null 'bech32' (v0) | 'bech32m' (v1+) | null (invalid)
*/
private static function bech32Verify(string $hrp, array $values): ?string
{
$polymod = self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values));
if ($polymod === 1) {
return 'bech32';
}
if ($polymod === 0x2bc830a3) {
return 'bech32m';
}
return null;
}
/** @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;
}
}