diff --git a/app/Services/Chain/BtcAddress.php b/app/Services/Chain/BtcAddress.php index 11d5c9b..611ff8f 100644 --- a/app/Services/Chain/BtcAddress.php +++ b/app/Services/Chain/BtcAddress.php @@ -92,6 +92,10 @@ final class BtcAddress 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'); } @@ -163,7 +167,8 @@ final class BtcAddress if (count($values) < 7) { throw new RuntimeException('Invalid bech32 length'); } - if (! self::bech32Verify($hrp, $values)) { + $spec = self::bech32Verify($hrp, $values); + if ($spec === null) { throw new RuntimeException('Invalid bech32 checksum'); } $values = array_slice($values, 0, -6); @@ -171,6 +176,13 @@ final class BtcAddress 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'); @@ -206,7 +218,7 @@ final class BtcAddress for ($i = 0; $i < strlen($bits); $i += 5) { $values[] = bindec(substr($bits, $i, 5)); } - $values = array_merge($values, self::bech32Checksum($hrp, $values)); + $values = array_merge($values, self::bech32Checksum($hrp, $values, $witver)); $result = $hrp.'1'; foreach ($values as $v) { $result .= $charset[$v]; @@ -216,14 +228,16 @@ final class BtcAddress } /** @param list $values */ - private static function bech32Checksum(string $hrp, array $values): array + 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 ^= 1; + $polymod ^= $const; $ret = []; for ($i = 0; $i < 6; $i++) { $ret[] = ($polymod >> 5 * (5 - $i)) & 31; @@ -232,10 +246,23 @@ final class BtcAddress return $ret; } - /** @param list $values */ - private static function bech32Verify(string $hrp, array $values): bool + /** + * Detect bech32/bech32m encoding from the checksum (BIP173 / BIP350). + * + * @param list $values + * @return string|null 'bech32' (v0) | 'bech32m' (v1+) | null (invalid) + */ + private static function bech32Verify(string $hrp, array $values): ?string { - return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1; + $polymod = self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)); + if ($polymod === 1) { + return 'bech32'; + } + if ($polymod === 0x2bc830a3) { + return 'bech32m'; + } + + return null; } /** @return list */