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>
This commit is contained in:
@@ -92,6 +92,10 @@ final class BtcAddress
|
|||||||
if ($ver === 0 && strlen($prog) === 32) {
|
if ($ver === 0 && strlen($prog) === 32) {
|
||||||
return ['type' => 'p2wsh', 'script' => '0020'.bin2hex($prog)];
|
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');
|
throw new RuntimeException('Unsupported bech32 witness program');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +167,8 @@ final class BtcAddress
|
|||||||
if (count($values) < 7) {
|
if (count($values) < 7) {
|
||||||
throw new RuntimeException('Invalid bech32 length');
|
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');
|
throw new RuntimeException('Invalid bech32 checksum');
|
||||||
}
|
}
|
||||||
$values = array_slice($values, 0, -6);
|
$values = array_slice($values, 0, -6);
|
||||||
@@ -171,6 +176,13 @@ final class BtcAddress
|
|||||||
if ($version > 16) {
|
if ($version > 16) {
|
||||||
throw new RuntimeException('Invalid witness version');
|
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);
|
$program = self::convertBits(array_slice($values, 1), 5, 8, false);
|
||||||
if ($program === null) {
|
if ($program === null) {
|
||||||
throw new RuntimeException('Invalid witness program');
|
throw new RuntimeException('Invalid witness program');
|
||||||
@@ -206,7 +218,7 @@ final class BtcAddress
|
|||||||
for ($i = 0; $i < strlen($bits); $i += 5) {
|
for ($i = 0; $i < strlen($bits); $i += 5) {
|
||||||
$values[] = bindec(substr($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';
|
$result = $hrp.'1';
|
||||||
foreach ($values as $v) {
|
foreach ($values as $v) {
|
||||||
$result .= $charset[$v];
|
$result .= $charset[$v];
|
||||||
@@ -216,14 +228,16 @@ final class BtcAddress
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<int> $values */
|
/** @param list<int> $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(
|
$polymod = self::bech32Polymod(array_merge(
|
||||||
self::bech32HrpExpand($hrp),
|
self::bech32HrpExpand($hrp),
|
||||||
$values,
|
$values,
|
||||||
[0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0],
|
||||||
));
|
));
|
||||||
$polymod ^= 1;
|
$polymod ^= $const;
|
||||||
$ret = [];
|
$ret = [];
|
||||||
for ($i = 0; $i < 6; $i++) {
|
for ($i = 0; $i < 6; $i++) {
|
||||||
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
|
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
|
||||||
@@ -232,10 +246,23 @@ final class BtcAddress
|
|||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<int> $values */
|
/**
|
||||||
private static function bech32Verify(string $hrp, array $values): bool
|
* 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
|
||||||
{
|
{
|
||||||
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<int> */
|
/** @return list<int> */
|
||||||
|
|||||||
Reference in New Issue
Block a user