From 30357c108f2235644b589d62ad85e62f2d20713f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 2 Oct 2026 19:28:00 +0000 Subject: [PATCH] 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 --- app/Services/Chain/BtcAddress.php | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) 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 */