diff --git a/.travis.yml b/.travis.yml index d12523885..5dcf19297 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ php: - 7.3 env: - - PHPUNIT=true PHPUNIT_EXT=true BITCOIN_VERSION="0.16.3" SECP256K1_COMMIT="cd329dbc3eaf096ae007e807b86b6f5947621ee3" + - PHPUNIT=true PHPUNIT_EXT=true BITCOIN_VERSION="0.16.3" SECP256K1_REMOTE="jonasnick/secp256k1" SECP256K1_COMMIT="1901f3bf9c6197f0bd3cc62e9f6c69296566a23a" dist: trusty sudo: required @@ -20,14 +20,14 @@ cache: matrix: exclude: - php: 7.2 - env: PHPUNIT=true PHPUNIT_EXT=true BITCOIN_VERSION="0.16.3" SECP256K1_COMMIT="cd329dbc3eaf096ae007e807b86b6f5947621ee3" + env: PHPUNIT=true PHPUNIT_EXT=true BITCOIN_VERSION="0.16.3" SECP256K1_REMOTE="jonasnick/secp256k1" SECP256K1_COMMIT="1901f3bf9c6197f0bd3cc62e9f6c69296566a23a" include: # add extra test runs for php7: coverage, codestyle, examples, rpc tests - php: 7.2 - env: COVERAGE=true CODE_STYLE=true EXAMPLES=true PHPUNIT=true PHPUNIT_EXT=true BITCOIN_VERSION="0.16.3" SECP256K1_COMMIT="cd329dbc3eaf096ae007e807b86b6f5947621ee3" + env: COVERAGE=true CODE_STYLE=true EXAMPLES=true PHPUNIT=true PHPUNIT_EXT=true BITCOIN_VERSION="0.16.3" SECP256K1_REMOTE="jonasnick/secp256k1" SECP256K1_COMMIT="1901f3bf9c6197f0bd3cc62e9f6c69296566a23a" - php: 7.0 - env: RPC_TEST=true BITCOIN_VERSION="0.16.3" SECP256K1_COMMIT="cd329dbc3eaf096ae007e807b86b6f5947621ee3" + env: RPC_TEST=true BITCOIN_VERSION="0.16.3" SECP256K1_REMOTE="jonasnick/secp256k1" SECP256K1_COMMIT="1901f3bf9c6197f0bd3cc62e9f6c69296566a23a" install: - | @@ -47,16 +47,17 @@ install: fi - | if [ "$PHPUNIT_EXT" = "true" ]; then - git clone https://github.com/bitcoin/secp256k1.git && + git clone https://github.com/${SECP256K1_REMOTE}.git && cd secp256k1 && git checkout ${SECP256K1_COMMIT} && - ./autogen.sh && ./configure --disable-jni --enable-module-recovery --enable-module-ecdh --enable-experimental && + ./autogen.sh && ./configure --disable-jni --enable-module-recovery --enable-module-ecdh --enable-module-schnorrsig --enable-experimental && make && sudo make install && cd ..; fi - | if [ "$PHPUNIT_EXT" = "true" ]; then - git clone -b v0.2.0 https://github.com/Bit-Wasp/secp256k1-php && + git clone https://github.com/afk11/secp256k1-php && cd secp256k1-php/secp256k1 && - phpize && ./configure && + git fetch origin schnorr2 && git checkout schnorr2 && + phpize && ./configure --with-secp256k1 --with-secp256k1-config --with-module-ecdh --with-module-recovery --with-module-schnorrsig && make && sudo make install && echo "extension=secp256k1.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini && cd ../..; fi - | @@ -77,6 +78,8 @@ before_script: - if [ "${COVERAGE}" != "true" ] && [ "$TRAVIS_PHP_VERSION" != "hhvm" ] && [ "$TRAVIS_PHP_VERSION" != "nightly" ]; then phpenv config-rm xdebug.ini && echo "xdebug disabled"; fi script: + - vendor/bin/phpunit --filter 'TaprootTest::testScript#20' + - vendor/bin/phpunit --filter 'TaprootTest::testScript#21' - travis/run_secp256k1_tests.sh || exit 1 - if [ "$COVERAGE" = "true" ]; then pwd && vendor/bin/phpstan analyse src tests -l 1; fi - make phpunit-ci || exit 1 diff --git a/composer.json b/composer.json index 7c6363a4b..7d15f3881 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,12 @@ "psr-4": { "BitWasp\\Bitcoin\\": "src/" }, - "files": ["src/Script/functions.php"] + "files": [ + "src/Script/functions.php", + "src/Script/sighash_functions.php", + "src/Script/Taproot/taproot_functions.php", + "src/Script/Interpreter/interpreter_constants.php" + ] }, "autoload-dev": { "psr-4": { @@ -24,6 +29,7 @@ }, "require": { "php-64bit": ">=7.0", + "ext-gmp": "*", "pleonasm/merkle-tree": "1.0.0", "composer/semver": "^1.4.0", "lastguest/murmurhash": "v2.0.0", diff --git a/src/Crypto/EcAdapter/EcSerializer.php b/src/Crypto/EcAdapter/EcSerializer.php index 2a8449a7c..9a27fbabf 100644 --- a/src/Crypto/EcAdapter/EcSerializer.php +++ b/src/Crypto/EcAdapter/EcSerializer.php @@ -8,8 +8,10 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\Adapter\EcAdapterInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Key\PrivateKeySerializerInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Key\PublicKeySerializerInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Key\XOnlyPublicKeySerializerInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Signature\CompactSignatureSerializerInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Signature\DerSignatureSerializerInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Signature\SchnorrSignatureSerializerInterface; class EcSerializer { @@ -22,8 +24,10 @@ class EcSerializer private static $serializerInterface = [ PrivateKeySerializerInterface::class, PublicKeySerializerInterface::class, + XOnlyPublicKeySerializerInterface::class, CompactSignatureSerializerInterface::class, DerSignatureSerializerInterface::class, + SchnorrSignatureSerializerInterface::class, ]; /** @@ -32,8 +36,10 @@ class EcSerializer private static $serializerImpl = [ 'Serializer\Key\PrivateKeySerializer', 'Serializer\Key\PublicKeySerializer', + 'Serializer\Key\XOnlyPublicKeySerializer', 'Serializer\Signature\CompactSignatureSerializer', - 'Serializer\Signature\DerSignatureSerializer' + 'Serializer\Signature\DerSignatureSerializer', + 'Serializer\Signature\SchnorrSignatureSerializer', ]; /** diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PrivateKey.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PrivateKey.php index c56231f09..f2f9467cc 100644 --- a/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PrivateKey.php +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PrivateKey.php @@ -8,12 +8,15 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\PhpEcc\Adapter\EcAdapter; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\PhpEcc\Serializer\Key\PrivateKeySerializer; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\PhpEcc\Signature\CompactSignature; +use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\PhpEcc\Signature\SchnorrSigner; +use BitWasp\Bitcoin\Crypto\EcAdapter\Key\XOnlyPublicKeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Signature\CompactSignatureInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\PhpEcc\Signature\Signature; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\Key; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\KeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\PrivateKeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\PublicKeyInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Signature\SchnorrSignatureInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Signature\SignatureInterface; use BitWasp\Bitcoin\Crypto\Random\RbgInterface; use BitWasp\Bitcoin\Crypto\Random\Rfc6979; @@ -116,6 +119,12 @@ public function signCompact(BufferInterface $msg32, RbgInterface $rbg = null): C ); } + public function signSchnorr(BufferInterface $msg32): SchnorrSignatureInterface + { + $schnorr = new SchnorrSigner($this->ecAdapter); + return $schnorr->sign($this, $msg32); + } + /** * @param \GMP $tweak * @return KeyInterface @@ -161,6 +170,16 @@ public function getPublicKey(): PublicKeyInterface return $this->publicKey; } + /** + * Return the public key + * + * @return XOnlyPublicKeyInterface + */ + public function getXOnlyPublicKey(): XOnlyPublicKeyInterface + { + return $this->getPublicKey()->asXOnlyPublicKey(); + } + /** * @param NetworkInterface $network * @return string diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PublicKey.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PublicKey.php index 939aa0d24..f84d643b4 100644 --- a/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PublicKey.php +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Key/PublicKey.php @@ -9,11 +9,15 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\Key\Key; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\KeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\PublicKeyInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Key\XOnlyPublicKeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Signature\SignatureInterface; use BitWasp\Buffertools\BufferInterface; use Mdanter\Ecc\Crypto\Signature\Signer; +use Mdanter\Ecc\Exception\SquareRootException; +use Mdanter\Ecc\Math\NumberTheory; use Mdanter\Ecc\Primitives\CurveFpInterface; use Mdanter\Ecc\Primitives\GeneratorPoint; +use Mdanter\Ecc\Primitives\Point; use Mdanter\Ecc\Primitives\PointInterface; class PublicKey extends Key implements PublicKeyInterface, \Mdanter\Ecc\Crypto\Key\PublicKeyInterface @@ -122,6 +126,36 @@ public function tweakMul(\GMP $tweak): KeyInterface return new PublicKey($this->ecAdapter, $point, $this->compressed); } + private function liftX(\GMP $x, PointInterface &$point = null): bool + { + $curve = $this->getCurve(); + $xCubed = gmp_powm($x, 3, $curve->getPrime()); + $v = gmp_add($xCubed, gmp_add( + gmp_mul($curve->getA(), $x), + $curve->getB() + )); + $math = $this->ecAdapter->getMath(); + $nt = new NumberTheory($math); + try { + $y = $nt->squareRootModP($v, $curve->getPrime()); + $point = new Point($math, $curve, $x, $y, $this->getGenerator()->getOrder()); + return true; + } catch (SquareRootException $e) { + return false; + } + } + + public function asXOnlyPublicKey(): XOnlyPublicKeyInterface + { + // todo: check this, see Secp version + $hasSquareY = gmp_cmp(gmp_jacobi($this->point->getY(), $this->getCurve()->getPrime()), gmp_init(1)) === 0; + $point = null; + if (!$this->liftX($this->point->getX(), $point)) { + throw new \RuntimeException("point has no square root"); + } + return new XOnlyPublicKey($this->ecAdapter, $point, $hasSquareY); + } + /** * @param BufferInterface $publicKey * @return bool diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Key/XOnlyPublicKey.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Key/XOnlyPublicKey.php new file mode 100644 index 000000000..3c641ac79 --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Key/XOnlyPublicKey.php @@ -0,0 +1,78 @@ +adapter = $adapter; + $this->point = $point; + $this->hasSquareY = $hasSquareY; + } + + public function hasSquareY(): bool + { + return $this->hasSquareY; + } + + public function getPoint(): PointInterface + { + return $this->point; + } + + public function verifySchnorr(BufferInterface $msg32, SchnorrSignatureInterface $schnorrSig): bool + { + $schnorr = new SchnorrSigner($this->adapter); + return $schnorr->verify($msg32, $this, $schnorrSig); + } + + public function tweakAdd(BufferInterface $tweak32): XOnlyPublicKeyInterface + { + $G = $this->adapter->getGenerator(); + $curve = $G->getCurve(); + $n = $G->getOrder(); + $gmpTweak = $tweak32->getGmp(); + if (gmp_cmp($gmpTweak, $n) >= 0) { + throw new \RuntimeException("invalid tweak"); + } + $offset = $this->adapter->getGenerator()->mul($gmpTweak); + $newPoint = $this->point->add($offset); + // todo: check this out + $hasSquareY = gmp_cmp(gmp_jacobi($newPoint->getY(), $curve->getPrime()), gmp_init(1)) === 0; + + return new XOnlyPublicKey($this->adapter, $newPoint, $hasSquareY); + } + + private function tweakTest(XOnlyPublicKeyInterface $base, BufferInterface $hash, bool $hasSquareY): bool + { + $pkExpected = $base->tweakAdd($hash); + $xEquals = gmp_cmp($pkExpected->getPoint()->getX(), $this->point->getX()) === 0; + $squareEquals = $pkExpected->hasSquareY() === $hasSquareY; + /** @var XOnlyPublicKey $pkExpected */ + return $xEquals && $squareEquals; + } + + public function checkPayToContract(XOnlyPublicKeyInterface $base, BufferInterface $hash, bool $negated): bool + { + return $this->tweakTest($base, $hash, !$negated); + } + + public function getBuffer(): BufferInterface + { + return Buffer::int(gmp_strval($this->point->getX(), 10), 32); + } +} diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Serializer/Key/XOnlyPublicKeySerializer.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Serializer/Key/XOnlyPublicKeySerializer.php new file mode 100644 index 000000000..2826cca5f --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Serializer/Key/XOnlyPublicKeySerializer.php @@ -0,0 +1,84 @@ +ecAdapter = $ecAdapter; + } + + private function doSerialize(XOnlyPublicKey $publicKey): BufferInterface + { + $x = $publicKey->getPoint()->getX(); + return Buffer::int(gmp_strval($x), 32); + } + + /** + * @param XOnlyPublicKeyInterface $publicKey + * @return BufferInterface + */ + public function serialize(XOnlyPublicKeyInterface $publicKey): BufferInterface + { + return $this->doSerialize($publicKey); + } + + private function liftX(\GMP $x, PointInterface &$point = null): bool + { + $generator = $this->ecAdapter->getGenerator(); + $curve = $generator->getCurve(); + $xCubed = gmp_powm($x, 3, $curve->getPrime()); + $v = gmp_add($xCubed, gmp_add( + gmp_mul($curve->getA(), $x), + $curve->getB() + )); + $math = $this->ecAdapter->getMath(); + $nt = new NumberTheory($math); + try { + $y = $nt->squareRootModP($v, $curve->getPrime()); + $point = new Point($math, $curve, $x, $y, $generator->getOrder()); + return true; + } catch (SquareRootException $e) { + return false; + } + } + + /** + * @param BufferInterface $buffer + * @return XOnlyPublicKeyInterface + */ + public function parse(BufferInterface $buffer): XOnlyPublicKeyInterface + { + if ($buffer->getSize() !== 32) { + throw new \RuntimeException("incorrect size"); + } + $x = $buffer->getGmp(); + $point = null; + // todo: review, might not need this + if (!$this->liftX($x, $point)) { + throw new \RuntimeException("No square root for this point"); + } + // todo: why pass hasSquareY again? + return new XOnlyPublicKey($this->ecAdapter, $point, true); + } +} diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Serializer/Signature/SchnorrSignatureSerializer.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Serializer/Signature/SchnorrSignatureSerializer.php new file mode 100644 index 000000000..623da1715 --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Serializer/Signature/SchnorrSignatureSerializer.php @@ -0,0 +1,64 @@ +ecAdapter = $ecAdapter; + } + + /** + * @param SchnorrSignature $sig + * @return BufferInterface + */ + private function doSerialize(SchnorrSignature $sig): BufferInterface + { + return Buffertools::concat( + Buffer::int(gmp_strval($sig->getR()), 32), + Buffer::int(gmp_strval($sig->getS()), 32) + ); + } + + /** + * @param SchnorrSignatureInterface $sig + * @return BufferInterface + */ + public function serialize(SchnorrSignatureInterface $sig): BufferInterface + { + /** @var SchnorrSignature $sig */ + return $this->doSerialize($sig); + } + + /** + * @param BufferInterface $buffer + * @return SchnorrSignatureInterface + * @throws \Exception + */ + public function parse(BufferInterface $buffer): SchnorrSignatureInterface + { + if ($buffer->getSize() !== 64) { + throw new \RuntimeException("schnorrsig must be 64 bytes"); + } + $r = $buffer->slice(0, 32)->getGmp(); + $s = $buffer->slice(32, 32)->getGmp(); + return new SchnorrSignature($r, $s); + } +} diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Signature/SchnorrSignature.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Signature/SchnorrSignature.php new file mode 100644 index 000000000..ab147dd9a --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Signature/SchnorrSignature.php @@ -0,0 +1,36 @@ +r = $r; + $this->s = $s; + } + + public function getR(): \GMP + { + return $this->r; + } + + public function getS(): \GMP + { + return $this->s; + } + + public function getBuffer(): BufferInterface + { + return Buffertools::concat(Buffer::int(gmp_strval($this->r), 32), Buffer::int(gmp_strval($this->s), 32)); + } +} diff --git a/src/Crypto/EcAdapter/Impl/PhpEcc/Signature/SchnorrSignatureInterface.php b/src/Crypto/EcAdapter/Impl/PhpEcc/Signature/SchnorrSignatureInterface.php new file mode 100644 index 000000000..37efa3d83 --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/PhpEcc/Signature/SchnorrSignatureInterface.php @@ -0,0 +1,9 @@ +adapter = $ecAdapter; + } + + /** + * @param PrivateKey $privateKey + * @param BufferInterface $message32 + * @return SchnorrSignature + * @throws \Exception + */ + public function sign(PrivateKey $privateKey, BufferInterface $message32): SchnorrSignature + { + $G = $this->adapter->getGenerator(); + $n = $G->getOrder(); + $d = $privateKey->getSecret(); + $P = $privateKey->getXOnlyPublicKey(); + if (!$P->hasSquareY()) { + $d = gmp_sub($n, $d); + } + $k = $this->hashPrivateData($d, $message32, $n); + if (gmp_cmp($k, 0) === 0) { + throw new \RuntimeException("unable to produce signature"); + } + $R = $G->mul($k); + if (gmp_jacobi($R->getY(), $G->getCurve()->getPrime()) !== 1) { + $k = gmp_sub($n, $k); + } + + $e = $this->hashPublicData($R->getX(), $privateKey->getXOnlyPublicKey(), $message32, $n); + $s = gmp_mod(gmp_add($k, gmp_mod(gmp_mul($e, $d), $n)), $n); + return new SchnorrSignature($R->getX(), $s); + } + + /** + * @param \GMP $n + * @return string + */ + private function tob32(\GMP $n): string + { + return $this->adapter->getMath()->intToFixedSizeString($n, 32); + } + + /** + * @param \GMP $secret + * @param BufferInterface $message32 + * @param \GMP $n + * @return \GMP + * @throws \Exception + */ + private function hashPrivateData(\GMP $secret, BufferInterface $message32, \GMP $n): \GMP + { + $hash = Hash::taggedSha256("BIPSchnorrDerive", new Buffer($this->tob32($secret) . $message32->getBinary())); + return gmp_mod($hash->getGmp(), $n); + } + + /** + * @param \GMP $Rx + * @param XOnlyPublicKey $publicKey + * @param BufferInterface $message32 + * @param \GMP $n + * @param string|null $rxBytes + * @return \GMP + * @throws \Exception + */ + private function hashPublicData(\GMP $Rx, XOnlyPublicKey $publicKey, BufferInterface $message32, \GMP $n, string &$rxBytes = null): \GMP + { + $rxBytes = $this->tob32($Rx); + $hash = Hash::taggedSha256("BIPSchnorr", new Buffer($rxBytes . $publicKey->getBinary() . $message32->getBinary())); + return gmp_mod(gmp_init($hash->getHex(), 16), $n); + } + + public function verify(BufferInterface $msg32, XOnlyPublicKey $publicKey, SchnorrSignature $signature): bool + { + $G = $this->adapter->getGenerator(); + $n = $G->getOrder(); + $p = $G->getCurve()->getPrime(); + + $r = $signature->getR(); + $s = $signature->getS(); + if (gmp_cmp($r, $p) >= 0 || gmp_cmp($s, $n) >= 0) { + return false; + } + + $RxBytes = null; + $e = $this->hashPublicData($r, $publicKey, $msg32, $n, $RxBytes); + $R = $G->mul($s)->add($publicKey->getPoint()->mul(gmp_sub($n, $e))); + $jacobiNotOne = gmp_jacobi($R->getY(), $p) !== 1; + $rxNotEquals = !hash_equals($RxBytes, $this->tob32($R->getX())); + if ($jacobiNotOne || $rxNotEquals) { + return false; + } + return true; + } +} diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PrivateKey.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PrivateKey.php index 144f094ce..b63b952aa 100644 --- a/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PrivateKey.php +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PrivateKey.php @@ -8,10 +8,13 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\Secp256k1\Adapter\EcAdapter; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\Secp256k1\Serializer\Key\PrivateKeySerializer; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\Secp256k1\Signature\CompactSignature; +use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\Secp256k1\Signature\SchnorrSignature; +use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\Secp256k1\Signature\SchnorrSignatureInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\Secp256k1\Signature\Signature; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\Key; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\KeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\PrivateKeyInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Key\XOnlyPublicKeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Signature\CompactSignatureInterface; use BitWasp\Bitcoin\Crypto\Random\RbgInterface; use BitWasp\Bitcoin\Exceptions\InvalidPrivateKey; @@ -70,6 +73,18 @@ public function __construct(EcAdapter $adapter, \GMP $secret, bool $compressed = $this->compressed = $compressed; } + public function signSchnorr(BufferInterface $msg32): \BitWasp\Bitcoin\Crypto\EcAdapter\Signature\SchnorrSignatureInterface + { + $context = $this->ecAdapter->getContext(); + + $schnorrSig = null; + if (1 !== secp256k1_schnorrsig_sign($context, $schnorrSig, $msg32->getBinary(), $this->secretBin)) { + throw new \RuntimeException("failed to sign transaction"); + } + + return new SchnorrSignature($context, $schnorrSig); + } + /** * @param BufferInterface $msg32 * @param RbgInterface|null $rbgInterface @@ -171,6 +186,11 @@ public function getPublicKey() return $this->publicKey; } + public function getXOnlyPublicKey(): XOnlyPublicKeyInterface + { + return $this->getPublicKey()->asXOnlyPublicKey(); + } + /** * @param \GMP $tweak * @return KeyInterface diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PublicKey.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PublicKey.php index 649bf55af..ce0c9d00f 100644 --- a/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PublicKey.php +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Key/PublicKey.php @@ -10,6 +10,7 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\Key\Key; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\KeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Key\PublicKeyInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Key\XOnlyPublicKeyInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Signature\SignatureInterface; use BitWasp\Buffertools\Buffer; use BitWasp\Buffertools\BufferInterface; @@ -168,6 +169,19 @@ public function tweakMul(\GMP $tweak): KeyInterface return new PublicKey($this->ecAdapter, $clone, $this->compressed); } + public function asXOnlyPublicKey(): XOnlyPublicKeyInterface + { + $context = $this->ecAdapter->getContext(); + $xonlyPubKey = null; + $hasSquareY = null; + if (1 !== secp256k1_xonly_pubkey_from_pubkey($context, $xonlyPubKey, $hasSquareY, $this->pubkey_t)) { + throw new \RuntimeException("Failed to convert pubkey to xonly pubkey"); + } + + /** @var resource $xonlyPubKey */ + return new XOnlyPublicKey($context, $xonlyPubKey, (bool) $hasSquareY); + } + /** * @return BufferInterface */ diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Key/XOnlyPublicKey.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Key/XOnlyPublicKey.php new file mode 100644 index 000000000..24a95c67b --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Key/XOnlyPublicKey.php @@ -0,0 +1,119 @@ +context = $context; + $this->xonlyKey = $xonlyKey; + $this->hasSquareY = $hasSquareY; + } + + public function hasSquareY(): bool + { + return $this->hasSquareY; + } + + private function doVerifySchnorr(BufferInterface $msg32, SchnorrSignature $schnorrSig): bool + { + return (bool) secp256k1_schnorrsig_verify($this->context, $schnorrSig->getResource(), $msg32->getBinary(), $this->xonlyKey); + } + + public function verifySchnorr(BufferInterface $msg32, SchnorrSignatureInterface $schnorrSignature): bool + { + /** @var SchnorrSignature $schnorrSignature */ + return $this->doVerifySchnorr($msg32, $schnorrSignature); + } + /** + * @return resource + * @throws \Exception + */ + private function clonePubkey() + { + $context = $this->context; + $serialized = ''; + if (1 !== secp256k1_xonly_pubkey_serialize($context, $serialized, $this->xonlyKey)) { + throw new \Exception('failed to serialize xonly pubkey for clone'); + } + + /** @var resource $clone */ + $clone = null; + if (1 !== secp256k1_xonly_pubkey_parse($context, $clone, $serialized)) { + throw new \Exception('failed to parse xonly pubkey'); + } + + return $clone; + } + + public function tweakAdd(BufferInterface $tweak32): XOnlyPublicKeyInterface + { + $pubkey = $this->clonePubkey(); + $tweaked = null; + $hasSquareY = null; + if (!secp256k1_xonly_pubkey_tweak_add($this->context, $tweaked, $hasSquareY, $pubkey, $tweak32->getBinary())) { + throw new \RuntimeException("failed to tweak pubkey"); + } + return new XOnlyPublicKey($this->context, $tweaked, (bool) $hasSquareY); + } + + private function doCheckPayToContract(XOnlyPublicKey $base, BufferInterface $hash, bool $negated): bool + { + if (1 !== secp256k1_xonly_pubkey_tweak_test($this->context, $this->xonlyKey, (int) !$negated, $base->xonlyKey, $hash->getBinary())) { + return false; + } + return true; + } + + public function checkPayToContract(XOnlyPublicKeyInterface $base, BufferInterface $hash, bool $negated): bool + { + /** @var XOnlyPublicKey $base */ + return $this->doCheckPayToContract($base, $hash, $negated); + } + + public function getBuffer(): BufferInterface + { + $out = ''; + if (!secp256k1_xonly_pubkey_serialize($this->context, $out, $this->xonlyKey)) { + throw new \RuntimeException("failed to serialize xonly pubkey!"); + } + return new Buffer($out); + } +} diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Serializer/Key/XOnlyPublicKeySerializer.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Serializer/Key/XOnlyPublicKeySerializer.php new file mode 100644 index 000000000..5050d5fb1 --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Serializer/Key/XOnlyPublicKeySerializer.php @@ -0,0 +1,74 @@ +ecAdapter = $ecAdapter; + } + + /** + * @param PublicKey $publicKey + * @return BufferInterface + */ + private function doSerialize(XOnlyPublicKey $publicKey) + { + $serialized = ''; + if (!secp256k1_xonly_pubkey_serialize( + $this->ecAdapter->getContext(), + $serialized, + $publicKey->getResource() + )) { + throw new \RuntimeException('Secp256k1: Failed to serialize xonly public key'); + } + + return new Buffer($serialized, 32); + } + + /** + * @param XOnlyPublicKeyInterface $publicKey + * @return BufferInterface + */ + public function serialize(XOnlyPublicKeyInterface $publicKey): BufferInterface + { + /** @var PublicKey $publicKey */ + return $this->doSerialize($publicKey); + } + + /** + * @param BufferInterface $buffer + * @return XOnlyPublicKeyInterface + */ + public function parse(BufferInterface $buffer): XOnlyPublicKeyInterface + { + $binary = $buffer->getBinary(); + $xonlyPubkey = null; + if (!secp256k1_xonly_pubkey_parse($this->ecAdapter->getContext(), $xonlyPubkey, $binary)) { + throw new \RuntimeException('Secp256k1 failed to parse xonly public key'); + } + /** @var resource $xonlyPubkey */ + return new XOnlyPublicKey( + $this->ecAdapter->getContext(), + $xonlyPubkey, + true + ); + } +} diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Serializer/Signature/SchnorrSignatureSerializer.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Serializer/Signature/SchnorrSignatureSerializer.php new file mode 100644 index 000000000..8d1dcc893 --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Serializer/Signature/SchnorrSignatureSerializer.php @@ -0,0 +1,69 @@ +ecAdapter = $ecAdapter; + } + + /** + * @param SchnorrSignature $signature + * @return BufferInterface + */ + private function doSerialize(SchnorrSignature $signature): BufferInterface + { + $sigOut = ''; + if (!secp256k1_schnorrsig_serialize($this->ecAdapter->getContext(), $sigOut, $signature->getResource())) { + throw new \RuntimeException('Secp256k1 serialize compact failure'); + } + + return new Buffer($sigOut, 64); + } + + /** + * @param SchnorrSignatureInterface $signature + * @return BufferInterface + */ + public function serialize(SchnorrSignatureInterface $signature): BufferInterface + { + /** @var SchnorrSignature $signature */ + return $this->doSerialize($signature); + } + + /** + * @param BufferInterface $sig + * @return SchnorrSignatureInterface + * @throws \Exception + */ + public function parse(BufferInterface $sig): SchnorrSignatureInterface + { + if ($sig->getSize() !== 64) { + throw new \RuntimeException('Compact Sig must be 65 bytes'); + } + + $sig_t = null; + if (!secp256k1_schnorrsig_parse($this->ecAdapter->getContext(), $sig_t, $sig->getBinary())) { + throw new \RuntimeException('Unable to parse compact signature'); + } + /** @var resource $sig_t */ + return new SchnorrSignature($this->ecAdapter->getContext(), $sig_t); + } +} diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Signature/SchnorrSignature.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Signature/SchnorrSignature.php new file mode 100644 index 000000000..24b931992 --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Signature/SchnorrSignature.php @@ -0,0 +1,30 @@ +context = $context; + $this->schnorrSig = $schnorrSig; + } + public function getResource() + { + return $this->schnorrSig; + } + public function getBuffer(): BufferInterface + { + $out = ''; + if (!secp256k1_schnorrsig_serialize($this->context, $out, $this->schnorrSig)) { + throw new \RuntimeException("failed to serialize schnorrsig"); + } + return new Buffer($out); + } +} diff --git a/src/Crypto/EcAdapter/Impl/Secp256k1/Signature/SchnorrSignatureInterface.php b/src/Crypto/EcAdapter/Impl/Secp256k1/Signature/SchnorrSignatureInterface.php new file mode 100644 index 000000000..f68cb96ea --- /dev/null +++ b/src/Crypto/EcAdapter/Impl/Secp256k1/Signature/SchnorrSignatureInterface.php @@ -0,0 +1,11 @@ +getBinary(), $salt->getBinary(), true)); } + + /** + * Creates a tagged sha256 hash per bip-schnorr + * + * @param string $tag + * @param BufferInterface $data + * @return BufferInterface + * @throws \Exception + */ + public static function taggedSha256(string $tag, BufferInterface $data): BufferInterface + { + $taghash = hash('sha256', $tag, true); + $ctx = hash_init('sha256'); + hash_update($ctx, $taghash); + hash_update($ctx, $taghash); + hash_update($ctx, $data->getBinary()); + return new Buffer(hash_final($ctx, true)); + } } diff --git a/src/Script/Consensus/NativeConsensus.php b/src/Script/Consensus/NativeConsensus.php index 448070420..c9077fea3 100644 --- a/src/Script/Consensus/NativeConsensus.php +++ b/src/Script/Consensus/NativeConsensus.php @@ -6,7 +6,10 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\Adapter\EcAdapterInterface; use BitWasp\Bitcoin\Script\Interpreter\Checker; use BitWasp\Bitcoin\Script\Interpreter\Interpreter; +use BitWasp\Bitcoin\Script\PrecomputedData; use BitWasp\Bitcoin\Script\ScriptInterface; +use BitWasp\Bitcoin\Serializer\Transaction\OutPointSerializer; +use BitWasp\Bitcoin\Serializer\Transaction\TransactionOutputSerializer; use BitWasp\Bitcoin\Transaction\TransactionInterface; class NativeConsensus implements ConsensusInterface @@ -15,6 +18,8 @@ class NativeConsensus implements ConsensusInterface * @var EcAdapterInterface */ private $adapter; + private $outPointSerializer; + private $txOutSerializer; /** * NativeConsensus constructor. @@ -23,6 +28,8 @@ class NativeConsensus implements ConsensusInterface public function __construct(EcAdapterInterface $ecAdapter = null) { $this->adapter = $ecAdapter ?: Bitcoin::getEcAdapter(); + $this->outPointSerializer = new OutPointSerializer(); + $this->txOutSerializer = new TransactionOutputSerializer(); } /** @@ -33,16 +40,26 @@ public function __construct(EcAdapterInterface $ecAdapter = null) * @param int $amount * @return bool */ - public function verify(TransactionInterface $tx, ScriptInterface $scriptPubKey, int $flags, int $nInputToSign, int $amount): bool + public function verify(TransactionInterface $tx, ScriptInterface $scriptPubKey, int $flags, int $nInputToSign, int $amount, array $spentTxOuts = null): bool { $inputs = $tx->getInputs(); $interpreter = new Interpreter($this->adapter); + $checker = new Checker($this->adapter, $tx, $nInputToSign, $amount); + if (null !== $spentTxOuts) { + $precomputed = new PrecomputedData($this->outPointSerializer, $this->txOutSerializer); + $precomputed->init($tx, $spentTxOuts); + $checker->setPrecomputedData($precomputed); + } + $wit = null; + if (array_key_exists($nInputToSign, $tx->getWitnesses())) { + $wit = $tx->getWitness($nInputToSign); + } return $interpreter->verify( $inputs[$nInputToSign]->getScript(), $scriptPubKey, $flags, - new Checker($this->adapter, $tx, $nInputToSign, $amount), - isset($tx->getWitnesses()[$nInputToSign]) ? $tx->getWitness($nInputToSign) : null + $checker, + $wit ); } } diff --git a/src/Script/Factory/OutputScriptFactory.php b/src/Script/Factory/OutputScriptFactory.php index 94d8958e4..ecc2d0055 100644 --- a/src/Script/Factory/OutputScriptFactory.php +++ b/src/Script/Factory/OutputScriptFactory.php @@ -193,4 +193,12 @@ public function witnessCoinbaseCommitment(BufferInterface $commitment): ScriptIn new Buffer("\xaa\x21\xa9\xed" . $commitment->getBinary()) ]); } + + public function taproot(BufferInterface $key32): ScriptInterface + { + if ($key32->getSize() !== 32) { + throw new \RuntimeException('Taproot key should be 32 bytes'); + } + return ScriptFactory::sequence([Opcodes::OP_1, $key32]); + } } diff --git a/src/Script/Interpreter/CheckerBase.php b/src/Script/Interpreter/CheckerBase.php index 196cbc5e7..1d16a7712 100644 --- a/src/Script/Interpreter/CheckerBase.php +++ b/src/Script/Interpreter/CheckerBase.php @@ -8,17 +8,22 @@ use BitWasp\Bitcoin\Crypto\EcAdapter\EcSerializer; use BitWasp\Bitcoin\Crypto\EcAdapter\Impl\PhpEcc\Key\PublicKey; use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Key\PublicKeySerializerInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Key\XOnlyPublicKeySerializerInterface; use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Signature\DerSignatureSerializerInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Signature\SchnorrSignatureSerializerInterface; use BitWasp\Bitcoin\Exceptions\ScriptRuntimeException; use BitWasp\Bitcoin\Exceptions\SignatureNotCanonical; use BitWasp\Bitcoin\Locktime; +use BitWasp\Bitcoin\Script\PrecomputedData; use BitWasp\Bitcoin\Script\ScriptInterface; use BitWasp\Bitcoin\Serializer\Signature\TransactionSignatureSerializer; use BitWasp\Bitcoin\Signature\TransactionSignature; use BitWasp\Bitcoin\Transaction\SignatureHash\SigHash; +use BitWasp\Bitcoin\Transaction\SignatureHash\TaprootHasher; use BitWasp\Bitcoin\Transaction\TransactionInput; use BitWasp\Bitcoin\Transaction\TransactionInputInterface; use BitWasp\Bitcoin\Transaction\TransactionInterface; +use BitWasp\Buffertools\Buffer; use BitWasp\Buffertools\BufferInterface; abstract class CheckerBase @@ -48,6 +53,11 @@ abstract class CheckerBase */ protected $sigCache = []; + /** + * @var array + */ + protected $schnorrSigHashCache = []; + /** * @var TransactionSignatureSerializer */ @@ -58,30 +68,62 @@ abstract class CheckerBase */ private $pubKeySerializer; + /** + * @var XOnlyPublicKeySerializerInterface + */ + private $xonlyKeySerializer; + + /** + * @var SchnorrSignatureSerializerInterface + */ + private $schnorrSigSerializer; + /** * @var int */ protected $sigHashOptionalBits = SigHash::ANYONECANPAY; /** - * Checker constructor. + * @var PrecomputedData + */ + protected $precomputedData; + + /** + * CheckerBase constructor. * @param EcAdapterInterface $ecAdapter * @param TransactionInterface $transaction * @param int $nInput * @param int $amount * @param TransactionSignatureSerializer|null $sigSerializer * @param PublicKeySerializerInterface|null $pubKeySerializer + * @param XOnlyPublicKeySerializerInterface|null $xonlyKeySerializer + * @param SchnorrSignatureSerializerInterface|null $schnorrSigSerializer */ - public function __construct(EcAdapterInterface $ecAdapter, TransactionInterface $transaction, int $nInput, int $amount, TransactionSignatureSerializer $sigSerializer = null, PublicKeySerializerInterface $pubKeySerializer = null) - { + public function __construct( + EcAdapterInterface $ecAdapter, + TransactionInterface $transaction, + int $nInput, + int $amount, + TransactionSignatureSerializer $sigSerializer = null, + PublicKeySerializerInterface $pubKeySerializer = null, + XOnlyPublicKeySerializerInterface $xonlyKeySerializer = null, + SchnorrSignatureSerializerInterface $schnorrSigSerializer = null + ) { $this->sigSerializer = $sigSerializer ?: new TransactionSignatureSerializer(EcSerializer::getSerializer(DerSignatureSerializerInterface::class, true, $ecAdapter)); $this->pubKeySerializer = $pubKeySerializer ?: EcSerializer::getSerializer(PublicKeySerializerInterface::class, true, $ecAdapter); + $this->xonlyKeySerializer = $xonlyKeySerializer ?: EcSerializer::getSerializer(XOnlyPublicKeySerializerInterface::class, true, $ecAdapter); + $this->schnorrSigSerializer = $schnorrSigSerializer ?: EcSerializer::getSerializer(SchnorrSignatureSerializerInterface::class, true, $ecAdapter); $this->adapter = $ecAdapter; $this->transaction = $transaction; $this->nInput = $nInput; $this->amount = $amount; } + public function setPrecomputedData(PrecomputedData $precomputedData) + { + $this->precomputedData = $precomputedData; + } + /** * @param ScriptInterface $script * @param int $hashType @@ -225,6 +267,57 @@ public function checkSig(ScriptInterface $script, BufferInterface $sigBuf, Buffe } } + public function getTaprootSigHash(int $sigHashType, int $sigVersion, ExecutionContext $execContext): BufferInterface + { + $cacheCheck = $sigVersion . $sigHashType; + if (!isset($this->schnorrSigHashCache[$cacheCheck])) { + $hasher = new TaprootHasher($this->transaction, $sigVersion, $this->precomputedData, $execContext); + $hash = $hasher->calculate($this->precomputedData->getSpentOutputs()[$this->nInput]->getScript(), $this->nInput, $sigHashType); + $this->schnorrSigHashCache[$cacheCheck] = $hash->getBinary(); + } else { + $hash = new Buffer($this->schnorrSigHashCache[$cacheCheck], 32); + } + + return $hash; + } + + public function checkSigSchnorr(BufferInterface $sig64, BufferInterface $key32, int $sigVersion, ExecutionContext $execContext): bool + { + if ($sig64->getSize() === 0) { + echo "sig64 = 0\n"; + return false; + } + if ($key32->getSize() !== 32) { + echo "key != 32\n"; + return false; + } + + $hashType = SigHash::TAPDEFAULT; + if ($sig64->getSize() === 65) { + $hashType = (int) $sig64->slice(64, 1)->getInt(); + if ($hashType === SigHash::TAPDEFAULT) { + echo "badsighash1\n"; + return false; + } + $sig64 = $sig64->slice(0, 64); + } + + if ($sig64->getSize() !== 64) { + echo "sig.size!=64\n"; + return false; + } + + try { + $sig = $this->schnorrSigSerializer->parse($sig64); + $pubKey = $this->xonlyKeySerializer->parse($key32); + $sigHash = $this->getTaprootSigHash($hashType, $sigVersion, $execContext); + return $pubKey->verifySchnorr($sigHash, $sig); + } catch (\Exception $e) { + echo "checksigSchnorr exception: ". $e->getMessage().PHP_EOL; + return false; + } + } + /** * @param \BitWasp\Bitcoin\Script\Interpreter\Number $scriptLockTime * @return bool diff --git a/src/Script/Interpreter/ExecutionContext.php b/src/Script/Interpreter/ExecutionContext.php new file mode 100644 index 000000000..1751fe96b --- /dev/null +++ b/src/Script/Interpreter/ExecutionContext.php @@ -0,0 +1,115 @@ +annexHash = $annexHash; + } + + public function setAnnexCheckDone() + { + $this->annexInit = true; + } + + public function isAnnexCheckDone(): bool + { + return $this->annexInit; + } + + public function hasAnnex(): bool + { + return null !== $this->annexHash; + } + + /** + * @return BufferInterface|null + */ + public function getAnnexHash() + { + return $this->annexHash; + } + + public function setTapLeafHash(BufferInterface $leafHash) + { + $this->tapLeafHash = $leafHash; + } + + public function hasTapLeaf(): bool + { + return null !== $this->tapLeafHash; + } + + /** + * @return BufferInterface|null + */ + public function getTapLeafHash() + { + return $this->tapLeafHash; + } + + public function setCodeSeparatorPosition(int $codeSepPos) + { + $this->codeSepPosition = $codeSepPos; + } + + public function getCodeSeparatorPosition(): int + { + return $this->codeSepPosition; + } + + public function hasValidationWeightSet(): bool + { + return null !== $this->validationWeightLeft; + } + + /** + * @return null|int + */ + public function getValidationWeightLeft() + { + return $this->validationWeightLeft; + } + + public function setValidationWeightLeft(int $weight) + { + $this->validationWeightLeft = $weight; + } +} diff --git a/src/Script/Interpreter/Interpreter.php b/src/Script/Interpreter/Interpreter.php index 2340cd1ff..42157005c 100644 --- a/src/Script/Interpreter/Interpreter.php +++ b/src/Script/Interpreter/Interpreter.php @@ -6,6 +6,8 @@ use BitWasp\Bitcoin\Bitcoin; use BitWasp\Bitcoin\Crypto\EcAdapter\Adapter\EcAdapterInterface; +use BitWasp\Bitcoin\Crypto\EcAdapter\EcSerializer; +use BitWasp\Bitcoin\Crypto\EcAdapter\Serializer\Key\XOnlyPublicKeySerializerInterface; use BitWasp\Bitcoin\Crypto\Hash; use BitWasp\Bitcoin\Exceptions\ScriptRuntimeException; use BitWasp\Bitcoin\Exceptions\SignatureNotCanonical; @@ -19,9 +21,12 @@ use BitWasp\Bitcoin\Script\WitnessProgram; use BitWasp\Bitcoin\Signature\TransactionSignature; use BitWasp\Bitcoin\Transaction\SignatureHash\SigHash; +use BitWasp\Bitcoin\Transaction\SignatureHash\TaprootHasher; use BitWasp\Bitcoin\Transaction\TransactionInputInterface; use BitWasp\Buffertools\Buffer; use BitWasp\Buffertools\BufferInterface; +use BitWasp\Buffertools\Buffertools; +use function BitWasp\Bitcoin\Script\isOPSuccess; class Interpreter implements InterpreterInterface { @@ -41,6 +46,11 @@ class Interpreter implements InterpreterInterface */ private $vchTrue; + /** + * @var EcAdapterInterface + */ + private $adapter; + /** * @var array */ @@ -51,6 +61,9 @@ class Interpreter implements InterpreterInterface Opcodes::OP_MOD, Opcodes::OP_LSHIFT, Opcodes::OP_RSHIFT ]; + const MAX_SCRIPT_SIZE = 10000; + const MAX_STACK_SIZE = 1000; + /** * @param EcAdapterInterface $ecAdapter */ @@ -58,6 +71,7 @@ public function __construct(EcAdapterInterface $ecAdapter = null) { $ecAdapter = $ecAdapter ?: Bitcoin::getEcAdapter(); $this->math = $ecAdapter->getMath(); + $this->adapter = $ecAdapter; $this->vchFalse = new Buffer("", 0); $this->vchTrue = new Buffer("\x01", 1); } @@ -144,20 +158,110 @@ private function checkOpcodeCount(int $count) return $this; } + /** + * Size of control must be validated before calling. + * @param BufferInterface $control + * @param BufferInterface $program + * @param BufferInterface $scriptPubKey + * @param BufferInterface|null $leafHash + * @return bool + * @throws \Exception + */ + private function verifyTaprootCommitment(BufferInterface $control, BufferInterface $program, BufferInterface $scriptPubKey, BufferInterface &$leafHash = null): bool + { + $m = ($control->getSize() - TAPROOT_CONTROL_BASE_SIZE) / TAPROOT_CONTROL_BRANCH_SIZE; + $p = $control->slice(1, 32); + /** @var XOnlyPublicKeySerializerInterface $xonlySer */ + $xonlySer = EcSerializer::getSerializer(XOnlyPublicKeySerializerInterface::class, true, $this->adapter); + try { + $P = $xonlySer->parse($p); + $Q = $xonlySer->parse($program); + } catch (\Exception $e) { + return false; + } + $leafVersion = $control->slice(0, 1)->getInt() & TAPROOT_LEAF_MASK; + + $leafData = new Buffer(chr($leafVersion&TAPROOT_LEAF_MASK) . Buffertools::numToVarIntBin($scriptPubKey->getSize()) . $scriptPubKey->getBinary()); + $k = Hash::taggedSha256("TapLeaf", $leafData); + $leafHash = $k; + for ($i = 0; $i < $m; $i++) { + $begin = TAPROOT_CONTROL_BASE_SIZE+TAPROOT_CONTROL_BRANCH_SIZE*$i; + $ej = $control->slice($begin, $begin + TAPROOT_CONTROL_BRANCH_SIZE); + if (strcmp($k->getBinary(), $ej->getBinary()) >= 0) { + $k = Hash::taggedSha256("TapBranch", Buffertools::concat($ej, $k)); + } else { + $k = Hash::taggedSha256("TapBranch", Buffertools::concat($k, $ej)); + } + } + + $t = Hash::taggedSha256("TapTweak", Buffertools::concat($p, $k)); + + $negated = (bool) (ord($control->getBinary()[0]) & 1); + + return $Q->checkPayToContract($P, $t, $negated); + } + + /** + * @param ScriptWitnessInterface $witness + * @param ScriptInterface $script + * @param int $sigVersion + * @param int $flags + * @param CheckerBase $checker + * @param ExecutionContext $execContext + * @return bool + */ + private function executeWitnessProgram(ScriptWitnessInterface $witness, ScriptInterface $script, int $sigVersion, int $flags, CheckerBase $checker, ExecutionContext $execContext): bool + { + if ($sigVersion === SigHash::TAPSCRIPT) { + foreach ($script->getScriptParser() as $operation) { + if (isOPSuccess($operation->getOp())) { + if (($flags & self::VERIFY_DISCOURAGE_OP_SUCCESS)) { + return false; + } + return true; + } + } + } + + $mainStack = new Stack(); + foreach ($witness as $value) { + if ($value->getSize() > self::MAX_SCRIPT_ELEMENT_SIZE) { + return false; + } + $mainStack->push($value); + } + + if (!$this->evaluate($script, $mainStack, $sigVersion, $flags, $checker, $execContext)) { + return false; + } + + if ($mainStack->count() !== 1) { + return false; + } + + if (!$this->castToBool($mainStack->bottom())) { + return false; + } + + return true; + } + /** * @param WitnessProgram $witnessProgram * @param ScriptWitnessInterface $scriptWitness * @param int $flags * @param CheckerBase $checker + * @param bool $isP2sh * @return bool */ - private function verifyWitnessProgram(WitnessProgram $witnessProgram, ScriptWitnessInterface $scriptWitness, int $flags, CheckerBase $checker): bool + private function verifyWitnessProgram(WitnessProgram $witnessProgram, ScriptWitnessInterface $scriptWitness, int $flags, CheckerBase $checker, bool $isP2sh): bool { $witnessCount = count($scriptWitness); + $execContext = new ExecutionContext(); if ($witnessProgram->getVersion() === 0) { - $buffer = $witnessProgram->getProgram(); - if ($buffer->getSize() === 32) { + $program = $witnessProgram->getProgram(); + if ($program->getSize() === 32) { // Version 0 segregated witness program: SHA256(Script) in program, Script + inputs in witness if ($witnessCount === 0) { // Must contain script at least @@ -165,46 +269,99 @@ private function verifyWitnessProgram(WitnessProgram $witnessProgram, ScriptWitn } $scriptPubKey = new Script($scriptWitness[$witnessCount - 1]); - $stackValues = $scriptWitness->slice(0, -1); - if (!$buffer->equals($scriptPubKey->getWitnessScriptHash())) { + /** @var ScriptWitnessInterface $stack */ + $stack = $scriptWitness->slice(0, -1); + if (!$program->equals($scriptPubKey->getWitnessScriptHash())) { return false; } - } elseif ($buffer->getSize() === 20) { + return $this->executeWitnessProgram($stack, $scriptPubKey, SigHash::V1, $flags, $checker, $execContext); + } elseif ($program->getSize() === 20) { // Version 0 special case for pay-to-pubkeyhash if ($witnessCount !== 2) { // 2 items in witness - return false; } - $scriptPubKey = ScriptFactory::scriptPubKey()->payToPubKeyHash($buffer); - $stackValues = $scriptWitness; + $scriptPubKey = ScriptFactory::scriptPubKey()->payToPubKeyHash($program); + return $this->executeWitnessProgram($scriptWitness, $scriptPubKey, SigHash::V1, $flags, $checker, $execContext); } else { return false; } - } elseif ($flags & self::VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM) { - return false; - } else { - // Unknown versions are always 'valid' to permit future soft forks - return true; } - $mainStack = new Stack(); - foreach ($stackValues as $value) { - $mainStack->push($value); - } + if ($witnessProgram->getVersion() === 1 && $witnessProgram->getProgram()->getSize() === 32 && !$isP2sh) { + if (!($flags & self::VERIFY_TAPROOT)) { + return true; + } - if (!$this->evaluate($scriptPubKey, $mainStack, SigHash::V1, $flags, $checker)) { - return false; - } + if ($witnessCount === 0) { + echo "empty witness\n"; + return false; + } else if ($witnessCount >= 2 && $scriptWitness->bottom()->getSize() > 0 && ord($scriptWitness->bottom()->getBinary()[0]) === TaprootHasher::TAPROOT_ANNEX_BYTE) { + $annex = $scriptWitness->bottom(); + if (($flags & self::VERIFY_DISCOURAGE_UPGRADABLE_ANNEX)) { + echo "uigradable annex\n"; + return false; + } + $execContext->setAnnexHash(Hash::sha256($annex)); + // remove annex from witness + $scriptWitness = $scriptWitness->slice(0, -1); + $witnessCount--; + } + $execContext->setAnnexCheckDone(); + if ($witnessCount === 1) { + // key spend path - doesn't use the interpreter, directly checks signature + $signature = $scriptWitness[count($scriptWitness) - 1]; + if (!$checker->checkSigSchnorr($signature, $witnessProgram->getProgram(), SigHash::TAPROOT, $execContext)) { + echo "invalid signature\n"; + return false; + } + return true; + } else { + // script spend path + // load control, and drop from end of witness + /** @var BufferInterface $control */ + $control = $scriptWitness->bottom(); + $scriptWitness = $scriptWitness->slice(0, -1); + + // load scriptPubKey, and drop from end of witness + /** @var BufferInterface $control */ + $scriptPubKey = $scriptWitness->bottom(); + $scriptWitness = $scriptWitness->slice(0, -1); + + if ($control->getSize() < TAPROOT_CONTROL_BASE_SIZE || + $control->getSize() > TAPROOT_CONTROL_MAX_SIZE || + (($control->getSize() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_BRANCH_SIZE !== 0)) { + echo "invalid control size\n"; + return false; + } - if ($mainStack->count() !== 1) { - return false; + $leafHash = null; + if (!$this->verifyTaprootCommitment($control, $witnessProgram->getProgram(), $scriptPubKey, $leafHash)) { + echo "invalid taproot commitment\n"; + return false; + } + $execContext->setTapLeafHash($leafHash); + + if ((ord($control->getBinary()[0]) & TAPROOT_LEAF_MASK) == TAPROOT_LEAF_TAPSCRIPT) { + // #Elements + [len(element) || element] for n + $execContext->setValidationWeightLeft($scriptWitness->getBuffer()->getSize() + VALIDATION_WEIGHT_OFFSET); + } + + // return true at this stage, need further work to proceed + $ret = $this->executeWitnessProgram($scriptWitness, new Script($scriptPubKey), SigHash::TAPSCRIPT, $flags, $checker, $execContext); + var_dump("witnessExec"); + var_dump($ret); + return $ret; + } } - if (!$this->castToBool($mainStack->bottom())) { + if ($flags & self::VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM) { + echo "upgradable witness program\n"; return false; } + // Return true to allow future softforks return true; } @@ -261,10 +418,9 @@ public function verify(ScriptInterface $scriptSig, ScriptInterface $scriptPubKey return false; } - if (!$this->verifyWitnessProgram($program, $witness, $flags, $checker)) { + if (!$this->verifyWitnessProgram($program, $witness, $flags, $checker, false)) { return false; } - $stack->resize(1); } } @@ -307,7 +463,7 @@ public function verify(ScriptInterface $scriptSig, ScriptInterface $scriptPubKey return false; // SCRIPT_ERR_WITNESS_MALLEATED_P2SH } - if (!$this->verifyWitnessProgram($program, $witness, $flags, $checker)) { + if (!$this->verifyWitnessProgram($program, $witness, $flags, $checker, true)) { return false; } @@ -356,25 +512,88 @@ public function checkExec(Stack $vfStack, bool $value): bool return (bool) $ret; } + private function evalChecksigPreTapscript(BufferInterface $sig, BufferInterface $key, ScriptInterface $scriptPubKey, int $hashStartPos, int $flags, CheckerBase $checker, int $sigVersion, bool &$success): bool + { + assert($sigVersion === SigHash::V0 || $sigVersion === SigHash::V1); + $scriptCode = new Script($scriptPubKey->getBuffer()->slice($hashStartPos)); + // encoding is checked in checker + $success = $checker->checkSig($scriptCode, $sig, $key, $sigVersion, $flags); + return true; + } + + private function evalChecksigTapscript(BufferInterface $sig, BufferInterface $key, int $flags, CheckerBase $checker, int $sigVersion, ExecutionContext $execContext, bool &$success): bool + { + assert($sigVersion === SigHash::TAPSCRIPT); + $success = $sig->getSize() > 0; + if ($success) { + assert($execContext->hasValidationWeightSet()); + $execContext->setValidationWeightLeft($execContext->getValidationWeightLeft() - VALIDATION_WEIGHT_OFFSET); + if ($execContext->getValidationWeightLeft() < 0) { + echo "validation weight failure\n"; + return false; + } + } + if ($key->getSize() === 0) { + echo "keysize=0\n"; + return false; + } else if ($key->getSize() === 32) { + if ($success && !$checker->checkSigSchnorr($sig, $key, $sigVersion, $execContext)) { + echo "keysize = 32 and checksig failed\n"; + return false; + } + } else { + if ($flags & self::VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE) { + echo "upgradable keytype\n"; + return false; + } + } + return true; + } + + private function evalChecksig(BufferInterface $sig, BufferInterface $key, ScriptInterface $scriptPubKey, int $hashStartPos, int $flags, CheckerBase $checker, int $sigVersion, ExecutionContext $execContext, bool &$success): bool + { + switch ($sigVersion) { + case SigHash::V0: + case SigHash::V1: + return $this->evalChecksigPreTapscript($sig, $key, $scriptPubKey, $hashStartPos, $flags, $checker, $sigVersion, $success); + case SigHash::TAPSCRIPT: + return $this->evalChecksigTapscript($sig, $key, $flags, $checker, $sigVersion, $execContext, $success); + case SigHash::TAPROOT: + break; + }; + assert(false); + } + /** * @param ScriptInterface $script * @param Stack $mainStack * @param int $sigVersion * @param int $flags * @param CheckerBase $checker + * @param ExecutionContext|null $execContext * @return bool */ - public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVersion, int $flags, CheckerBase $checker): bool + public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVersion, int $flags, CheckerBase $checker, ExecutionContext $execContext = null): bool { + if ($execContext === null) { + $execContext = new ExecutionContext(); + } $hashStartPos = 0; $opCount = 0; + $opCodePos = 0; $zero = gmp_init(0, 10); $altStack = new Stack(); $vfStack = new Stack(); $minimal = ($flags & self::VERIFY_MINIMALDATA) !== 0; $parser = $script->getScriptParser(); - if ($script->getBuffer()->getSize() > 10000) { + // script limit applies to base, and v0 segwit, but not tapscript + if (($sigVersion === SigHash::V0 || $sigVersion === SigHash::V1) && $script->getBuffer()->getSize() > self::MAX_SCRIPT_SIZE) { + return false; + } + + // stack limit applies to initial stack under tapscript + if ($sigVersion === SigHash::TAPSCRIPT && $mainStack->count() > self::MAX_STACK_SIZE) { return false; } @@ -389,9 +608,12 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers throw new \RuntimeException('Error - push size'); } - // OP_RESERVED should not count towards opCount - if ($opCode > Opcodes::OP_16 && ++$opCount) { - $this->checkOpcodeCount($opCount); + // non-push opcode limit applies to base & v0 segwit, but not tapscript + if ($sigVersion === SigHash::V0 || $sigVersion === SigHash::V1) { + // OP_RESERVED should not count towards opCount + if ($opCode > Opcodes::OP_16 && ++$opCount) { + $this->checkOpcodeCount($opCount); + } } if (in_array($opCode, $this->disabledOps, true)) { @@ -405,9 +627,9 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers } $mainStack->push($pushData); - // echo " - [pushed '" . $pushData->getHex() . "']\n"; + echo " - [pushed '" . $pushData->getHex() . "']\n"; } elseif ($fExec || (Opcodes::OP_IF <= $opCode && $opCode <= Opcodes::OP_ENDIF)) { - // echo "OPCODE - " . $script->getOpcodes()->getOp($opCode) . "\n"; + echo "OPCODE - " . $script->getOpcodes()->getOp($opCode) . "\n"; switch ($opCode) { case Opcodes::OP_1NEGATE: case Opcodes::OP_1: @@ -502,7 +724,8 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers } $vch = $mainStack[-1]; - if ($sigVersion === SigHash::V1 && ($flags & self::VERIFY_MINIMALIF)) { + // minimalif is a standardness rule in v0 segwit, but required in tapscript + if ($sigVersion === SigHash::TAPSCRIPT || ($sigVersion === SigHash::V1 && ($flags & self::VERIFY_MINIMALIF))) { if ($vch->getSize() > 1) { throw new ScriptRuntimeException(self::VERIFY_MINIMALIF, 'Input to OP_IF/NOTIF should be minimally encoded'); } @@ -866,6 +1089,32 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers case Opcodes::OP_CODESEPARATOR: $hashStartPos = $parser->getPosition(); + $execContext->setCodeSeparatorPosition($opCodePos); + break; + + case Opcodes::OP_CHECKSIGADD: + if ($sigVersion !== SigHash::TAPSCRIPT) { + echo "sigVersion != tapscript\n"; + throw new \RuntimeException('Opcode not found'); + } + if ($mainStack->count() < 3) { + echo "mainStack count != 3\n"; + return false; + } + $pubkey = $mainStack[-1]; + $n = Number::buffer($mainStack[-2], $minimal, Number::MAX_NUM_SIZE, $this->math); + $sig = $mainStack[-3]; + + $success = false; + if (!$this->evalChecksig($sig, $pubkey, $script, $hashStartPos, $flags, $checker, $sigVersion, $execContext, $success)) { + echo "checksig add - evalChecksig false\n"; + return false; + } + $push = Number::gmp($this->math->add($n->getGmp(), gmp_init($success ? 1 : 0, 10)), $this->math)->getBuffer(); + $mainStack->pop(); + $mainStack->pop(); + $mainStack->pop(); + $mainStack->push($push); break; case Opcodes::OP_CHECKSIG: @@ -877,9 +1126,10 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers $vchPubKey = $mainStack[-1]; $vchSig = $mainStack[-2]; - $scriptCode = new Script($script->getBuffer()->slice($hashStartPos)); - - $success = $checker->checkSig($scriptCode, $vchSig, $vchPubKey, $sigVersion, $flags); + $success = false; + if (!$this->evalChecksig($vchSig, $vchPubKey, $script, $hashStartPos, $flags, $checker, $sigVersion, $execContext, $success)) { + return false; + } $mainStack->pop(); $mainStack->pop(); @@ -900,6 +1150,9 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers case Opcodes::OP_CHECKMULTISIG: case Opcodes::OP_CHECKMULTISIGVERIFY: + if ($sigVersion === SigHash::TAPSCRIPT) { + throw new \RuntimeException('Disabled Opcode'); + } $i = 1; if (count($mainStack) < $i) { throw new \RuntimeException('Invalid stack operation'); @@ -997,9 +1250,11 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers throw new \RuntimeException('Opcode not found'); } - if (count($mainStack) + count($altStack) > 1000) { + if (count($mainStack) + count($altStack) > self::MAX_STACK_SIZE) { throw new \RuntimeException('Invalid stack size, exceeds 1000'); } + + $opCodePos++; } } @@ -1009,11 +1264,11 @@ public function evaluate(ScriptInterface $script, Stack $mainStack, int $sigVers return true; } catch (ScriptRuntimeException $e) { - // echo "\n Runtime: " . $e->getMessage() . "\n" . $e->getTraceAsString() . PHP_EOL; + echo "\n Runtime: " . $e->getMessage() . "\n" . $e->getTraceAsString() . PHP_EOL; // Failure due to script tags, can access flag: $e->getFailureFlag() return false; } catch (\Exception $e) { - // echo "\n General: " . $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL; + echo "\n General: " . $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL; return false; } } diff --git a/src/Script/Interpreter/InterpreterInterface.php b/src/Script/Interpreter/InterpreterInterface.php index 25d4fc173..6a34c7aff 100644 --- a/src/Script/Interpreter/InterpreterInterface.php +++ b/src/Script/Interpreter/InterpreterInterface.php @@ -76,6 +76,12 @@ interface InterpreterInterface const VERIFY_NULLFAIL = 1 << 14; + const VERIFY_TAPROOT = 1 << 17; + + const VERIFY_DISCOURAGE_UPGRADABLE_ANNEX = 1 << 19; + + const VERIFY_DISCOURAGE_OP_SUCCESS = 1 << 20; + const VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE = 1 << 21; // Verify CHECKSEQUENCEVERIFY // // See BIP112 for details. diff --git a/src/Script/Interpreter/interpreter_constants.php b/src/Script/Interpreter/interpreter_constants.php new file mode 100644 index 000000000..f5f58a83c --- /dev/null +++ b/src/Script/Interpreter/interpreter_constants.php @@ -0,0 +1,11 @@ + 'OP_NOP8', self::OP_NOP9 => 'OP_NOP9', self::OP_NOP10 => 'OP_NOP10', + self::OP_CHECKSIGADD => 'OP_CHECKSIGADD', ]; /** diff --git a/src/Script/PrecomputedData.php b/src/Script/PrecomputedData.php new file mode 100644 index 000000000..dc52ea785 --- /dev/null +++ b/src/Script/PrecomputedData.php @@ -0,0 +1,109 @@ +outPointSerializer = $outPointSerializer; + $this->txOutSerializer = $txOutSerializer; + } + + public function init(TransactionInterface $tx, array $spentOutputs = null) + { + if ($this->ready) { + return; + } + if (null === $spentOutputs) { + $spentOutputs = []; + } + if ($tx->hasWitness()) { + $this->prevoutsSha256 = SighashGetPrevoutsSha256($this->outPointSerializer, $tx); + $this->sequencesSha256 = SighashGetSequencesSha256($tx); + $this->outputsSha256 = SighashGetOutputsSha256($this->txOutSerializer, $tx); + + $this->prevoutsHash = Hash::sha256($this->prevoutsSha256); + $this->sequencesHash = Hash::sha256($this->sequencesSha256); + $this->outputsHash = Hash::sha256($this->outputsSha256); + + if (count($spentOutputs) > 0) { + $this->spentTxOuts = $spentOutputs; + $this->spentAmountsSha256 = SighashGetSpentAmountsHash($this->txOutSerializer, $spentOutputs); + } + + $this->ready = true; + } + } + + public function isReady(): bool + { + return $this->ready; + } + + public function haveSpentOutputs(): bool + { + return count($this->spentTxOuts) > 0; + } + + public function getPrevoutsSha256(): BufferInterface + { + return $this->prevoutsSha256; + } + public function getPrevoutsHash(): BufferInterface + { + return $this->prevoutsHash; + } + + public function getSequencesSha256(): BufferInterface + { + return $this->sequencesSha256; + } + public function getSequencesHash(): BufferInterface + { + return $this->sequencesHash; + } + + public function getOutputsSha256(): BufferInterface + { + return $this->outputsSha256; + } + public function getOutputsHash(): BufferInterface + { + return $this->outputsHash; + } + + /** + * @return TransactionOutputInterface[] + */ + public function getSpentOutputs(): array + { + return $this->spentTxOuts; + } + public function getSpentAmountsSha256(): BufferInterface + { + return $this->spentAmountsSha256; + } +} diff --git a/src/Script/Taproot/taproot_functions.php b/src/Script/Taproot/taproot_functions.php new file mode 100644 index 000000000..286966dbc --- /dev/null +++ b/src/Script/Taproot/taproot_functions.php @@ -0,0 +1,90 @@ +getSize()) . + $scriptBytes->getBinary() + )); + return $ret; +} + +function hashTapBranch(BufferInterface $left, BufferInterface $right): BufferInterface +{ + $hash = Hash::taggedSha256("TapBranch", Buffertools::concat(...Buffertools::sort([$left, $right]))); + return $hash; +} + +function taprootTreeHelper(array $scripts): array +{ + if (is_array($scripts) && count($scripts) == 1) { + if (count($scripts[0]) == 2 && is_int($scripts[0][0]) && $scripts[0][1] instanceof \BitWasp\Bitcoin\Script\ScriptInterface) { + list ($leafVersion, $script) = $scripts[0]; + if (!($script instanceof \BitWasp\Bitcoin\Script\ScriptInterface)) { + throw new \RuntimeException("leaf[1] not a script"); + } + + $leafHash = hashTapLeaf($leafVersion, $script->getBuffer()); + return [ + [ + [$leafVersion, $script, new Buffer() /*leafcontrol*/] + ], + $leafHash, + ]; + } else { + return taprootTreeHelper($scripts[0]); + } + } + + $split = intdiv(count($scripts), 2); + $listLeft = array_slice($scripts, 0, $split); + $listRight = array_slice($scripts, $split); + + list ($left, $left_hash) = taprootTreeHelper($listLeft); + list ($right, $right_hash) = taprootTreeHelper($listRight); + /** @var BufferInterface $left_hash */ + /** @var BufferInterface $right_hash */ + $left2 = []; + foreach ($left as list($version, $script, $control)) { + $left2[] = [$version, $script, Buffertools::concat($control, $right_hash)]; + } + $right2 = []; + foreach ($right as list($version, $script, $control)) { + $right2[] = [$version, $script, Buffertools::concat($control, $left_hash)]; + } + + $hash = hashTapBranch($left_hash, $right_hash); + return [array_merge($left2, $right2), $hash]; +} + +function taprootConstruct(XOnlyPublicKeyInterface $internalKey, array $scripts): array +{ + $keyBytes = $internalKey->getBuffer(); + if (count($scripts) == 0) { + return [ScriptFactory::scriptPubKey()->taproot($keyBytes), null, [], []]; + } + + list ($ret, $hash) = taprootTreeHelper($scripts); + $tweak = Hash::taggedSha256("TapTweak", new Buffer($keyBytes->getBinary() . $hash->getBinary())); + $outputKey = $internalKey->tweakAdd($tweak); + $controlList = []; + $scriptList = []; + foreach ($ret as list ($version, $script, $control)) { + $scriptList[] = $script; + $controlList[] = chr(($version & TAPROOT_LEAF_MASK) + ($outputKey->hasSquareY() ? 0 : 1)) . + $keyBytes->getBinary() . + $control->getBinary(); + } + return [ScriptFactory::scriptPubKey()->taproot($outputKey->getBuffer()), $tweak, $scriptList, $controlList]; +} diff --git a/src/Script/functions.php b/src/Script/functions.php index e05de6630..2e70715c9 100644 --- a/src/Script/functions.php +++ b/src/Script/functions.php @@ -29,3 +29,14 @@ function encodeOpN(int $op): int return Opcodes::OP_1 + $op - 1; } + +function isOPSuccess(int $op): bool +{ + return $op === 80 || $op === 98 || + ($op >= 126 && $op <= 129) || + ($op >= 131 && $op <= 134) || + ($op >= 137 && $op <= 138) || + ($op >= 141 && $op <= 142) || + ($op >= 149 && $op <= 153) || + ($op >= 187 && $op <= 254); +} diff --git a/src/Script/sighash_functions.php b/src/Script/sighash_functions.php new file mode 100644 index 000000000..0cfcfde12 --- /dev/null +++ b/src/Script/sighash_functions.php @@ -0,0 +1,70 @@ +getInputs() as $input) { + $binary .= $outPointSerializer->serialize($input->getOutPoint())->getBinary(); + } + return Hash::sha256(new Buffer($binary)); +} + +/** + * @param OutPointSerializerInterface $outPointSerializer + * @param TransactionInterface $tx + * @return BufferInterface + */ +function SighashGetSequencesSha256(TransactionInterface $tx): BufferInterface +{ + $binary = ''; + foreach ($tx->getInputs() as $input) { + $binary .= pack("V", $input->getSequence()); + } + return Hash::sha256(new Buffer($binary)); +} + +/** + * @param TransactionOutputSerializer $txOutSerializer + * @param TransactionInterface $tx + * @return BufferInterface + */ +function SighashGetOutputsSha256(TransactionOutputSerializer $txOutSerializer, TransactionInterface $tx): BufferInterface +{ + $binary = ''; + foreach ($tx->getOutputs() as $output) { + $binary .= $txOutSerializer->serialize($output)->getBinary(); + } + return Hash::sha256(new Buffer($binary)); +} + +/** + * @param TransactionOutputSerializer $txOutSerializer + * @param TransactionOutputInterface[] $spentTxOuts + * @return BufferInterface + */ +function SighashGetSpentAmountsHash(TransactionOutputSerializer $txOutSerializer, array $spentTxOuts): BufferInterface +{ + $amounts = []; + $count = count($spentTxOuts); + for ($i = 0; $i < $count; $i++) { + $amounts[] = $spentTxOuts[$i]->getValue(); + } + return Hash::sha256(new Buffer(pack(str_repeat("P", $count), ...$amounts))); +} diff --git a/src/Transaction/SignatureHash/SigHash.php b/src/Transaction/SignatureHash/SigHash.php index ee97fcae0..5a049f028 100644 --- a/src/Transaction/SignatureHash/SigHash.php +++ b/src/Transaction/SignatureHash/SigHash.php @@ -12,6 +12,8 @@ abstract class SigHash implements SigHashInterface { const V0 = 0; const V1 = 1; + const TAPROOT = 2; + const TAPSCRIPT = 3; /** * @var TransactionInterface diff --git a/src/Transaction/SignatureHash/SigHashInterface.php b/src/Transaction/SignatureHash/SigHashInterface.php index 61f802ec8..b57fa2fa7 100644 --- a/src/Transaction/SignatureHash/SigHashInterface.php +++ b/src/Transaction/SignatureHash/SigHashInterface.php @@ -30,6 +30,10 @@ interface SigHashInterface */ const ANYONECANPAY = 128; + const TAPDEFAULT = 0x00; + const TAPOUTPUTMASK = 0x03; + const TAPINPUTMASK = 0x80; + /** * Calculate the hash of the current transaction, when you are looking to * spend $txOut, and are signing $inputToSign. The SigHashType defaults to diff --git a/src/Transaction/SignatureHash/TaprootHasher.php b/src/Transaction/SignatureHash/TaprootHasher.php new file mode 100644 index 000000000..bb733e19f --- /dev/null +++ b/src/Transaction/SignatureHash/TaprootHasher.php @@ -0,0 +1,166 @@ +sigVersion = $sigVersion; + $this->execContext = $execContext; + $this->precomputedData = $precomputedData; + $this->outputSerializer = $outputSerializer ?: new TransactionOutputSerializer(); + $this->outpointSerializer = $outpointSerializer ?: new OutPointSerializer(); + if (!($precomputedData->isReady() && $precomputedData->haveSpentOutputs())) { + throw new \RuntimeException("precomputed data not ready"); + } + if (!$execContext->isAnnexCheckDone()) { + throw new \RuntimeException("annex check must be already complete"); + } + parent::__construct($transaction); + } + + /** + * Calculate the hash of the current transaction, when you are looking to + * spend $txOut, and are signing $inputToSign. The SigHashType defaults to + * SIGHASH_ALL + * + * Note: this function doesn't use txOutScript, as we have access to it via + * spentOutputs. + * + * @param ScriptInterface $txOutScript + * @param int $inputToSign + * @param int $sighashType + * @return BufferInterface + * @throws \Exception + */ + public function calculate( + ScriptInterface $txOutScript, + int $inputToSign, + int $sighashType = SigHash::ALL + ): BufferInterface { + if (($sighashType > 3) && ($sighashType < 0x81 || $sighashType > 0x83)) { + throw new \RuntimeException("invalid hash type"); + } + $epoch = 0; + $input = $this->tx->getInput($inputToSign); + + $ss = ''; + $ss .= pack("C", $epoch); + $ss .= pack('CVV', $sighashType, $this->tx->getVersion(), $this->tx->getLockTime()); + + $inputType = $sighashType & SigHash::TAPINPUTMASK; + $outputType = $sighashType & SigHash::TAPOUTPUTMASK; + + if ($inputType === SigHash::TAPDEFAULT) { + $ss .= $this->precomputedData->getPrevoutsSha256()->getBinary(); + $ss .= $this->precomputedData->getSpentAmountsSha256()->getBinary(); + $ss .= $this->precomputedData->getSequencesSha256()->getBinary(); + } + if ($outputType === SigHash::TAPDEFAULT || $outputType === SigHash::ALL) { + $ss .= $this->precomputedData->getOutputsSha256()->getBinary(); + } + + $scriptPubKey = $this->precomputedData->getSpentOutputs()[$inputToSign]->getScript()->getBuffer(); + $spendType = 0; + if ($this->execContext->hasAnnex()) { + $spendType |= 1; + } + if ($this->sigVersion === SigHash::TAPSCRIPT) { + $spendType |= 2; + } + + $ss .= pack('C', $spendType); + $ss .= Buffertools::numToVarIntBin($scriptPubKey->getSize()) . $scriptPubKey->getBinary(); + + if ($inputType === SigHash::ANYONECANPAY) { + $ss .= $this->outpointSerializer->serialize($input->getOutPoint())->getBinary(); + $ss .= pack('P', $this->precomputedData->getSpentOutputs()[$inputToSign]->getValue()); + $ss .= pack('V', $input->getSequence()); + } else { + $ss .= pack('V', $inputToSign); + } + if ($this->execContext->hasAnnex()) { + $ss .= $this->execContext->getAnnexHash()->getBinary(); + } + + if ($outputType == SigHash::SINGLE) { + $outputs = $this->tx->getOutputs(); + if ($inputToSign >= count($outputs)) { + throw new \RuntimeException("sighash single input > #outputs"); + } + $ss .= Hash::sha256($this->outputSerializer->serialize($outputs[$inputToSign]))->getBinary(); + } + + if ($this->sigVersion == SigHash::TAPSCRIPT) { + assert($this->execContext->hasTapLeaf()); + $ss .= $this->execContext->getTapLeafHash()->getBinary(); + $ss .= "\x00"; // key version + $ss .= pack("V", $this->execContext->getCodeSeparatorPosition()); + } + + $ret = Hash::taggedSha256('TapSighash', new Buffer($ss)); + return $ret; + } +} diff --git a/src/Transaction/SignatureHash/V1Hasher.php b/src/Transaction/SignatureHash/V1Hasher.php index 9ac7e9722..3d23a5b03 100644 --- a/src/Transaction/SignatureHash/V1Hasher.php +++ b/src/Transaction/SignatureHash/V1Hasher.php @@ -13,6 +13,9 @@ use BitWasp\Buffertools\Buffer; use BitWasp\Buffertools\BufferInterface; use BitWasp\Buffertools\Buffertools; +use function BitWasp\Bitcoin\Script\SighashGetOutputsSha256; +use function BitWasp\Bitcoin\Script\SighashGetPrevoutsSha256; +use function BitWasp\Bitcoin\Script\SighashGetSequencesSha256; class V1Hasher extends SigHash { @@ -62,11 +65,7 @@ public function __construct( public function hashPrevOuts(int $sighashType): BufferInterface { if (!($sighashType & SigHash::ANYONECANPAY)) { - $binary = ''; - foreach ($this->tx->getInputs() as $input) { - $binary .= $this->outpointSerializer->serialize($input->getOutPoint())->getBinary(); - } - return Hash::sha256d(new Buffer($binary)); + return Hash::sha256(SighashGetPrevoutsSha256($this->outpointSerializer, $this->tx)); } return new Buffer('', 32); @@ -79,12 +78,7 @@ public function hashPrevOuts(int $sighashType): BufferInterface public function hashSequences(int $sighashType): BufferInterface { if (!($sighashType & SigHash::ANYONECANPAY) && ($sighashType & 0x1f) !== SigHash::SINGLE && ($sighashType & 0x1f) !== SigHash::NONE) { - $binary = ''; - foreach ($this->tx->getInputs() as $input) { - $binary .= pack('V', $input->getSequence()); - } - - return Hash::sha256d(new Buffer($binary)); + return Hash::sha256(SighashGetSequencesSha256($this->tx)); } return new Buffer('', 32); @@ -98,11 +92,7 @@ public function hashSequences(int $sighashType): BufferInterface public function hashOutputs(int $sighashType, int $inputToSign): BufferInterface { if (($sighashType & 0x1f) !== SigHash::SINGLE && ($sighashType & 0x1f) !== SigHash::NONE) { - $binary = ''; - foreach ($this->tx->getOutputs() as $output) { - $binary .= $this->outputSerializer->serialize($output)->getBinary(); - } - return Hash::sha256d(new Buffer($binary)); + return Hash::sha256(SighashGetOutputsSha256($this->outputSerializer, $this->tx)); } elseif (($sighashType & 0x1f) === SigHash::SINGLE && $inputToSign < count($this->tx->getOutputs())) { return Hash::sha256d($this->outputSerializer->serialize($this->tx->getOutput($inputToSign))); } diff --git a/tests/Crypto/EcAdapter/PhpeccSchnorrSignerTest.php b/tests/Crypto/EcAdapter/PhpeccSchnorrSignerTest.php new file mode 100644 index 000000000..948fabf48 --- /dev/null +++ b/tests/Crypto/EcAdapter/PhpeccSchnorrSignerTest.php @@ -0,0 +1,181 @@ +getCompliantSignatureFixtures() as $vector) { + foreach ($this->getEcAdapters() as $adapter) { + $datasets[] = [$adapter[0], $vector[0], $vector[1], $vector[2], $vector[3]]; + } + } + + return $datasets; + } + + public function getVerificationFixtures(): array + { + return [ + [ + /*$pubKey = */ "03D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9", + /*$msg32 = */ "4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703", + /*$sig64 = */ "00000000000000000000003B78CE563F89A0ED9414F5AA28AD0D96D6795F9C63EE374AC7FAE927D334CCB190F6FB8FD27A2DDC639CCEE46D43F113A4035A2C7F", + ], + ]; + } + + public function adapterVerificationFixtures(): array + { + $datasets = []; + + foreach ($this->getVerificationFixtures() as $vector) { + foreach ($this->getEcAdapters() as $adapter) { + $datasets[] = [$adapter[0], $vector[0], $vector[1], $vector[2]]; + } + } + + return $datasets; + } + + public function getNegativeVerificationFixtures(): array + { + return [ + [ + /*$pubKey = */ "02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + /*$msg32 = */ "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + /*$sig64 = */ "2A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1DFA16AEE06609280A19B67A24E1977E4697712B5FD2943914ECD5F730901B4AB7", + /*$reason = */ "incorrect R residuosity", + ], + [ + /*$pubKey = */ "03FAC2114C2FBB091527EB7C64ECB11F8021CB45E8E7809D3C0938E4B8C0E5F84B", + /*$msg32 = */ "5E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C", + /*$sig64 = */ "00DA9B08172A9B6F0466A2DEFD817F2D7AB437E0D253CB5395A963866B3574BED092F9D860F1776A1F7412AD8A1EB50DACCC222BC8C0E26B2056DF2F273EFDEC", + /*$reason = */ "negated message hash", + ], + [ + /*$pubKey = */ "0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", + /*$msg32 = */ "0000000000000000000000000000000000000000000000000000000000000000", + /*$sig64 = */ "787A848E71043D280C50470E8E1532B2DD5D20EE912A45DBDD2BD1DFBF187EF68FCE5677CE7A623CB20011225797CE7A8DE1DC6CCD4F754A47DA6C600E59543C", + /*$reason = */ "negated s value", + ], + [ + /*$pubKey = */ "03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + /*$msg32 = */ "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + /*$sig64 = */ "2A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D1E51A22CCEC35599B8F266912281F8365FFC2D035A230434A1A64DC59F7013FD", + /*$reason = */ "negated public key", + ], + ]; + } + + public function adapterNegativeVerificationFixtures(): array + { + $datasets = []; + + foreach ($this->getNegativeVerificationFixtures() as $vector) { + foreach ($this->getEcAdapters() as $adapter) { + $datasets[] = [$adapter[0], $vector[0], $vector[1], $vector[2]]; + } + } + + return $datasets; + } + /** + * @dataProvider adapterCompliantFixtures + * @param string $privKey + * @param string $pubKey + * @param string $msg32 + * @param string $sig64 + * @throws \Exception + */ + public function testSignatureFixtures(EcAdapterInterface $ecAdapter, string $privKey, string $pubKey, string $msg32, string $sig64) + { + $privFactory = new PrivateKeyFactory($ecAdapter); + $priv = $privFactory->fromHexCompressed($privKey); + $pub = $priv->getPublicKey(); + $msg = Buffer::hex($msg32); + $signature = $priv->signSchnorr($msg); + $xonlyPub = $pub->asXOnlyPublicKey(); + + $this->assertEquals(strtolower($sig64), $signature->getHex()); + $this->assertTrue($xonlyPub->verifySchnorr($msg, $signature)); + } + + /** + * @dataProvider adapterVerificationFixtures + * @param string $pubKey + * @param string $msg32 + * @param string $sig64 + * @throws \Exception + */ + public function testPositiveVerification(EcAdapterInterface $ecAdapter, string $pubKey, string $msg32, string $sig64) + { + //$ecAdapter = EcAdapterFactory::getPhpEcc(new Math(), EccFactory::getSecgCurves()->generator256k1()); + $pubKeyFactory = new PublicKeyFactory($ecAdapter); + $pub = $pubKeyFactory->fromHex($pubKey); + $msg = Buffer::hex($msg32); + /** @var SchnorrSignatureSerializerInterface $serializer */ + $serializer = EcSerializer::getSerializer(SchnorrSignatureSerializerInterface::class, true, $ecAdapter); + $sigBuf = Buffer::hex($sig64); + $signature = $serializer->parse($sigBuf); + $this->assertTrue($pub->asXOnlyPublicKey()->verifySchnorr($msg, $signature)); + } + + /** + * @dataProvider adapterNegativeVerificationFixtures + * @param string $pubKey + * @param string $msg32 + * @param string $sig64 + * @throws \Exception + */ + public function testNegativeVerification(EcAdapterInterface $ecAdapter, string $pubKey, string $msg32, string $sig64) + { + //$ecAdapter = EcAdapterFactory::getPhpEcc(new Math(), EccFactory::getSecgCurves()->generator256k1()); + $pubKeyFactory = new PublicKeyFactory($ecAdapter); + $pub = $pubKeyFactory->fromHex($pubKey); + $msg = Buffer::hex($msg32); + + $sigBuf = Buffer::hex($sig64); + /** @var SchnorrSignatureSerializerInterface $serializer */ + $serializer = EcSerializer::getSerializer(SchnorrSignatureSerializerInterface::class, true, $ecAdapter); + $signature = $serializer->parse($sigBuf); + $this->assertFalse($pub->asXOnlyPublicKey()->verifySchnorr($msg, $signature)); + } +} diff --git a/tests/Key/Deterministic/HierarchicalKeyTest.php b/tests/Key/Deterministic/HierarchicalKeyTest.php index f1cfa4621..49d2c390a 100644 --- a/tests/Key/Deterministic/HierarchicalKeyTest.php +++ b/tests/Key/Deterministic/HierarchicalKeyTest.php @@ -483,8 +483,10 @@ function () { ->setMethods([ 'getSecret', 'getPublicKey', + 'getXOnlyPublicKey', 'sign', 'signCompact', + 'signSchnorr', 'toWif', // Key Interface diff --git a/tests/Script/ConsensusTest.php b/tests/Script/ConsensusTest.php index c930e5fb1..ebffe469a 100644 --- a/tests/Script/ConsensusTest.php +++ b/tests/Script/ConsensusTest.php @@ -46,6 +46,10 @@ public function prepareConsensusTests() $vectors = []; foreach ($this->prepareTestData() as $fixture) { list ($flags, $returns, $scriptWitness, $scriptSig, $scriptPubKey, $amount, $strTest) = $fixture; + $spentOutputs = []; + if (count($fixture) > 7) { + $spentOutputs = $fixture[7]; + } foreach ($adapters as $consensusFixture) { list ($consensus) = $consensusFixture; @@ -59,7 +63,7 @@ public function prepareConsensusTests() } } - $vectors[] = [$consensus, $flags, $returns, $scriptWitness, $scriptSig, $scriptPubKey, $amount, $strTest]; + $vectors[] = [$consensus, $flags, $returns, $scriptWitness, $scriptSig, $scriptPubKey, $amount, $strTest, $spentOutputs]; } } @@ -85,11 +89,12 @@ public function testScript( ScriptInterface $scriptSig, ScriptInterface $scriptPubKey, int $amount, - string $strTest + string $strTest, + array $spentOutputs = [] ) { $create = $this->buildCreditingTransaction($scriptPubKey, $amount); $tx = $this->buildSpendTransaction($create, $scriptSig, $scriptWitness); - $check = $consensus->verify($tx, $scriptPubKey, $flags, 0, $amount); + $check = $consensus->verify($tx, $scriptPubKey, $flags, 0, $amount, $spentOutputs); $this->assertEquals($expectedResult, $check, $strTest); } diff --git a/tests/Script/Taproot/TaprootConstructTest.php b/tests/Script/Taproot/TaprootConstructTest.php new file mode 100644 index 000000000..bcf4c9a9e --- /dev/null +++ b/tests/Script/Taproot/TaprootConstructTest.php @@ -0,0 +1,299 @@ +assertCount(4, $list, 'result should have 4 items in list'); + $this->assertInstanceOf(ScriptInterface::class, $list[0]); + if ($scriptCount > 0) { + $this->assertInstanceOf(BufferInterface::class, $list[1], 'expecting tweak if scripts set'); + } else { + $this->assertNull($list[1], 'no tweak if there are no scripts in tree'); + } + + $this->assertInternalType('array', $list[2], 'scripts should always be an array'); + if ($scriptCount > 0) { + $this->assertCount($scriptCount, $list[2]); + foreach ($list[2] as $script) { + $this->assertInstanceOf(ScriptInterface::class, $script); + } + } else { + $this->assertEmpty($list[2], 'scripts should be empty when called with empty list'); + } + + $this->assertInternalType('array', $list[3], 'control should always be an array'); + if ($scriptCount > 0) { + $this->assertCount($scriptCount, $list[3]); + foreach ($list[3] as $control) { + $this->assertInternalType('string', $control); + // check min + $this->assertTrue(strlen($control) >= TAPROOT_CONTROL_BASE_SIZE); + // check max + $this->assertTrue(strlen($control) <= TAPROOT_CONTROL_MAX_SIZE); + // check size-base evenly divides TAPROOT_CONTROL_BRANCH_SIZE + $this->assertEquals(0, (strlen($control) - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_BRANCH_SIZE); + } + } else { + $this->assertEmpty($list[3], 'control should be empty when called with empty list'); + } + } + + /** + * Verifies scriptPubKey by checking: + * - witness version == 1 + * - witness program == xonly if no tweak, xonly.tweakAdd(tweak) otherwise + * Returns the external key + * @param ScriptInterface $scriptPubKey + * @param XOnlyPublicKeyInterface $xonly + * @param BufferInterface|null $tweak + * @return XOnlyPublicKeyInterface + */ + private function verifyTaprootWitnessProgram(ScriptInterface $scriptPubKey, XOnlyPublicKeyInterface $xonly, BufferInterface $tweak = null): XOnlyPublicKeyInterface + { + $wp = null; + $this->assertTrue($scriptPubKey->isWitness($wp)); + /** @var WitnessProgram $wp */ + $this->assertEquals(1, $wp->getVersion()); + $outputKey = $xonly; + if ($tweak !== null) { + $outputKey = $outputKey->tweakAdd($tweak); + } + $this->assertEquals($wp->getProgram()->getHex(), $outputKey->getHex()); + return $outputKey; + } + + /** + * If no scripts are passed, then + * - externalKey == internalKey + * - tweak is null + * - scripts is empty array + * - control is empty array + * + * @dataProvider getEcAdapters + * @param EcAdapterInterface $adapter + * @throws \Exception + */ + public function testNoScriptsIsSimplyInternalKey(EcAdapterInterface $adapter) + { + $xonlyBytes = Buffer::hex('f44bc3e92e304464d33664cdb5ed75c204cb2786a40d4882551a66b0065faa11'); + /** @var XOnlyPublicKeySerializerInterface $xonlySerializer */ + $xonlySerializer = EcSerializer::getSerializer(XOnlyPublicKeySerializerInterface::class, true, $adapter); + $xonly = $xonlySerializer->parse($xonlyBytes); + $wp = null; + + $tree = []; + + /** @var ScriptInterface $scriptPubKey */ + $ret = taprootConstruct($xonly, $tree); + $this->verifyConstructList($ret, 0); + list ($scriptPubKey, $tweak, $scripts, $control) = $ret; + + // scriptPubKey + $this->verifyTaprootWitnessProgram($scriptPubKey, $xonly, $tweak); + } + + /** + * Tests taprootConstruct with 1 script, and checks control per below + * + * [leaf1] + * | + * + * leaf control + * 1 leafVersion+internal+'' + * + * @dataProvider getEcAdapters + * @param EcAdapterInterface $adapter + * @throws \Exception + */ + public function testSingleScript(EcAdapterInterface $adapter) + { + $xonlyBytes = Buffer::hex('3cb5bdfd40d1f7bc059216b2db1708f876311be1c08dfe68b553c1c99084ce88'); + /** @var XOnlyPublicKeySerializerInterface $xonlySerializer */ + $xonlySerializer = EcSerializer::getSerializer(XOnlyPublicKeySerializerInterface::class, true, $adapter); + $xonly = $xonlySerializer->parse($xonlyBytes); + $wp = null; + + $p2pkh1 = ScriptFactory::scriptPubKey()->p2pkh(new Buffer(str_repeat("A", 20))); + $tree = [[TAPROOT_LEAF_TAPSCRIPT, $p2pkh1]]; + + /** @var ScriptInterface $scriptPubKey */ + $ret = taprootConstruct($xonly, $tree); + $this->verifyConstructList($ret, 1); + list ($scriptPubKey, $tweak, $scripts, $control) = $ret; + + // scriptPubKey + $outputKey = $this->verifyTaprootWitnessProgram($scriptPubKey, $xonly, $tweak); + + // tweak + /** @var BufferInterface $hash */ + list (, $hash) = taprootTreeHelper($tree); + $tweakCheck = Hash::taggedSha256("TapTweak", new Buffer($xonly->getBinary() . $hash->getBinary())); + $this->assertEquals($tweakCheck->getHex(), $tweak->getHex()); + + // scripts + $this->assertEquals($p2pkh1->getHex(), $scripts[0]->getHex()); + + // control + // no extra hashes as first level deep + $expectControl = chr((TAPROOT_LEAF_TAPSCRIPT & TAPROOT_LEAF_MASK) + ($outputKey->hasSquareY() ? 0 : 1)) . $xonly->getBinary() . ''; + $this->assertEquals($expectControl, $control[0]); + } + + /** + * Tests taprootConstruct with 2 scripts, and checks control per below + * + * [leaf1] [leaf2] + * |_______| + * | + * + * leaf control + * 1 leafVersion+internal+hashTapLeaf2 + * 2 leafVersion+internal+hashTapLeaf1 + * + * @dataProvider getEcAdapters + * @param EcAdapterInterface $adapter + * @throws \Exception + */ + public function testTwoScripts(EcAdapterInterface $adapter) + { + $xonlyBytes = Buffer::hex('3b39617a6d966e9728ee0853e28fc9a22a995ef6b12c1eeb45047774f614290c'); + /** @var XOnlyPublicKeySerializerInterface $xonlySerializer */ + $xonlySerializer = EcSerializer::getSerializer(XOnlyPublicKeySerializerInterface::class, true, $adapter); + $xonly = $xonlySerializer->parse($xonlyBytes); + $wp = null; + + $p2pkh1 = ScriptFactory::scriptPubKey()->p2pkh(new Buffer(str_repeat("A", 20))); + $p2pkh2 = ScriptFactory::scriptPubKey()->p2pkh(new Buffer(str_repeat("B", 20))); + $tree = [ + [TAPROOT_LEAF_TAPSCRIPT, $p2pkh1,], + [TAPROOT_LEAF_TAPSCRIPT, $p2pkh2,], + ]; + + /** @var ScriptInterface $scriptPubKey */ + $ret = taprootConstruct($xonly, $tree); + + $this->verifyConstructList($ret, 2); + list ($scriptPubKey, $tweak, $scripts, $control) = $ret; + + // scriptPubKey + $outputKey = $this->verifyTaprootWitnessProgram($scriptPubKey, $xonly, $tweak); + + // tweak + /** @var BufferInterface $hash */ + list (, $hash) = taprootTreeHelper($tree); + $tweakCheck = Hash::taggedSha256("TapTweak", new Buffer($xonly->getBinary() . $hash->getBinary())); + $this->assertEquals($tweakCheck->getHex(), $tweak->getHex()); + + // scripts + $this->assertEquals($p2pkh1->getHex(), $scripts[0]->getHex()); + $this->assertEquals($p2pkh2->getHex(), $scripts[1]->getHex()); + + // control + // no extra hashes as first level deep + $leaf1 = hashTapLeaf(TAPROOT_LEAF_TAPSCRIPT, $scripts[0]->getBuffer()); + $leaf2 = hashTapLeaf(TAPROOT_LEAF_TAPSCRIPT, $scripts[1]->getBuffer()); + + $controlBase = chr((TAPROOT_LEAF_TAPSCRIPT & TAPROOT_LEAF_MASK) + ($outputKey->hasSquareY() ? 0 : 1)) . $xonly->getBinary(); + $this->assertEquals($controlBase . $leaf2->getBinary(), $control[0]); + $this->assertEquals($controlBase . $leaf1->getBinary(), $control[1]); + } + + /** + * Tests taprootConstruct with 3 scripts, and checks control per below + * + * [leaf1] [leaf2] + * |_______| [leaf3] + * |_________| + * | + * + * leaf control + * 1 leafVersion+internal+hashTapLeaf2+hashTapLeaf3 + * 2 leafVersion+internal+hashTapLeaf1+hashTapLeaf3 + * 3 leafVersion+internal+hashTapBranch(hashTapLeaf1,hashTapLeaf2) + * + * @dataProvider getEcAdapters + * @param EcAdapterInterface $adapter + * @throws \Exception + */ + public function testThreeScripts(EcAdapterInterface $adapter) + { + $xonlyBytes = Buffer::hex('3b39617a6d966e9728ee0853e28fc9a22a995ef6b12c1eeb45047774f614290c'); + /** @var XOnlyPublicKeySerializerInterface $xonlySerializer */ + $xonlySerializer = EcSerializer::getSerializer(XOnlyPublicKeySerializerInterface::class, true, $adapter); + $xonly = $xonlySerializer->parse($xonlyBytes); + $wp = null; + + $p2pkh1 = ScriptFactory::scriptPubKey()->p2pkh(new Buffer(str_repeat("A", 20))); + $p2pkh2 = ScriptFactory::scriptPubKey()->p2pkh(new Buffer(str_repeat("B", 20))); + $p2pkh3 = ScriptFactory::scriptPubKey()->p2pkh(new Buffer(str_repeat("C", 20))); + + $tree = [ + [ + [TAPROOT_LEAF_TAPSCRIPT, $p2pkh1,], + [TAPROOT_LEAF_TAPSCRIPT, $p2pkh2,], + ], + [TAPROOT_LEAF_TAPSCRIPT, $p2pkh3,], + ]; + + /** @var ScriptInterface $scriptPubKey */ + $ret = taprootConstruct($xonly, $tree); + + $this->verifyConstructList($ret, 3); + list ($scriptPubKey, $tweak, $scripts, $control) = $ret; + + // scriptPubKey + $outputKey = $this->verifyTaprootWitnessProgram($scriptPubKey, $xonly, $tweak); + + // tweak + /** @var BufferInterface $hash */ + list (, $hash) = taprootTreeHelper($tree); + $tweakCheck = Hash::taggedSha256("TapTweak", new Buffer($xonly->getBinary() . $hash->getBinary())); + $this->assertEquals($tweakCheck->getHex(), $tweak->getHex()); + + // scripts + $this->assertEquals($p2pkh1->getHex(), $scripts[0]->getHex()); + $this->assertEquals($p2pkh2->getHex(), $scripts[1]->getHex()); + $this->assertEquals($p2pkh3->getHex(), $scripts[2]->getHex()); + + // control + // no extra hashes as first level deep + $leaf1 = hashTapLeaf(TAPROOT_LEAF_TAPSCRIPT, $scripts[0]->getBuffer()); + $leaf2 = hashTapLeaf(TAPROOT_LEAF_TAPSCRIPT, $scripts[1]->getBuffer()); + $leaf3 = hashTapLeaf(TAPROOT_LEAF_TAPSCRIPT, $scripts[2]->getBuffer()); + $branch12 = hashTapBranch($leaf1, $leaf2); + + $controlBase = chr((TAPROOT_LEAF_TAPSCRIPT & TAPROOT_LEAF_MASK) + ($outputKey->hasSquareY() ? 0 : 1)) . $xonly->getBinary(); + $this->assertEquals($controlBase . $leaf2->getBinary() . $leaf3->getBinary(), $control[0]); + $this->assertEquals($controlBase . $leaf1->getBinary() . $leaf3->getBinary(), $control[1]); + $this->assertEquals($controlBase . $branch12->getBinary(), $control[2]); + } +} diff --git a/tests/Script/TaprootTest.php b/tests/Script/TaprootTest.php new file mode 100644 index 000000000..66c5c3b58 --- /dev/null +++ b/tests/Script/TaprootTest.php @@ -0,0 +1,123 @@ +calcMapOpNames($opcodes); + return [ + //[$flags, $returns, $scriptWitness, $scriptSig, $scriptPubKey, $amount, $strTest], + [0, true, new ScriptWitness(), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'should return true when segwit & taproot not active'], + [I::VERIFY_WITNESS, true, new ScriptWitness(), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'should return true when taproot not active'], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'should return false if scriptWitness empty'], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT|I::VERIFY_DISCOURAGE_UPGRADABLE_ANNEX, false, new ScriptWitness(new Buffer(), new Buffer(), new Buffer("\x50")), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'VERIFY_DISCOURAGE_UPGRADABLE_ANNEX rejects annex if present'], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(new Buffer(), new Buffer(), new Buffer(str_repeat("A", 32))), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'control size wrong: (under minimum)'], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(new Buffer(), new Buffer(), new Buffer(str_repeat("A", 33+32*128+1))), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'control size wrong: (over maximum)'], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(new Buffer(), new Buffer(), new Buffer(str_repeat("A", 33+16))), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xabcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'), 0, 'control size wrong: ((len-33)%32!=0)'], + + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, true, new ScriptWitness(Buffer::hex('b2ecb4b1957d495d55c72e3deabb3d76ed8d33c2496ffdbdc8577b9605ceec84732f6eb3d90c89931f42d0a76499aa2b493f44e53c2c9d74a3565fd8fecc4bbd')), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x1dcf456c1195e3bd19fe33e52309859253ddc9b95996b0896e36fd3df34eb66a'), 1, 'keypath signature ok', [new TransactionOutput(1, new Script(Buffer::hex("51201dcf456c1195e3bd19fe33e52309859253ddc9b95996b0896e36fd3df34eb66a")))]], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(Buffer::hex('11ecb4b1957d495d55c72e3deabb3d76ed8d33c2496ffdbdc8577b9605ceec84732f6eb3d90c89931f42d0a76499aa2b493f44e53c2c9d74a3565fd8fecc4bbd')), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x1dcf456c1195e3bd19fe33e52309859253ddc9b95996b0896e36fd3df34eb66a'), 1, 'keypath signature r first byte wrong', [new TransactionOutput(1, new Script(Buffer::hex("51201dcf456c1195e3bd19fe33e52309859253ddc9b95996b0896e36fd3df34eb66a")))]], + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(Buffer::hex('b2ecb4b1957d495d55c72e3deabb3d76ed8d33c2496ffdbdc8577b9605ceec84112f6eb3d90c89931f42d0a76499aa2b493f44e53c2c9d74a3565fd8fecc4bbd')), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x1dcf456c1195e3bd19fe33e52309859253ddc9b95996b0896e36fd3df34eb66a'), 1, 'keypath signature s first byte wrong', [new TransactionOutput(1, new Script(Buffer::hex("51201dcf456c1195e3bd19fe33e52309859253ddc9b95996b0896e36fd3df34eb66a")))]], + + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, true, new ScriptWitness(Buffer::hex('613f2d05989a9b82d407663c96d9f599428f5e4a3cb450d49c8292ebc2a44fc877b90d2273cc54911b5ae466b1ac10b6e7c8c972f31253eba12c14073ef957e901'), + Buffer::hex('0020b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99ba519c'), + Buffer::hex('c0b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99') + ), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7'), 1, 'tapscript 1of1 checksigadd', [new TransactionOutput(1, new Script(Buffer::hex("5120070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7")))]], + + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(Buffer::hex('613f2d05989a9b82d407663c96d9f599428f5e4a3cb450d49c8292ebc2a44fc877b90d2273cc54911b5ae466b1ac10b6e7c8c972f31253eba12c14073ef957e901'), + Buffer::hex('0020b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99ba519c'), + Buffer::hex('c0b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99') + ), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0xfffff82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7'), 1, 'first 2 bytes spk wrong', [new TransactionOutput(1, new Script(Buffer::hex("5120070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7")))]], + + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(Buffer::hex('613f2d05989a9b82d407663c96d9f599428f5e4a3cb450d49c8292ebc2a44fc877b90d2273cc54911b5ae466b1ac10b6e7c8c972f31253eba12c14073ef957e901'), + Buffer::hex('ff20b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99ba519c'), + Buffer::hex('c0b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99') + ), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7'), 1, '1st byte script element incorrect', [new TransactionOutput(1, new Script(Buffer::hex("5120070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7")))]], + + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(Buffer::hex('613f2d05989a9b82d407663c96d9f599428f5e4a3cb450d49c8292ebc2a44fc877b90d2273cc54911b5ae466b1ac10b6e7c8c972f31253eba12c14073ef957e901'), + Buffer::hex('0020b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99ba519c'), + Buffer::hex('c1b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99') + ), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7'), 1, 'is_square_y bit incorrect', [new TransactionOutput(1, new Script(Buffer::hex("5120070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7")))]], + + [I::VERIFY_WITNESS|I::VERIFY_TAPROOT, false, new ScriptWitness(Buffer::hex('613f2d05989a9b82d407663c96d9f599428f5e4a3cb450d49c8292ebc2a44fc877b90d2273cc54911b5ae466b1ac10b6e7c8c972f31253eba12c14073ef957e901'), + Buffer::hex('0020b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99ba519c'), + Buffer::hex('c0ffffbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99') + ), new Script(), $this->calcScriptFromString($mapOpNames, '0x51 0x20 0x070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7'), 1, '1st 2 bytes control hash incorrect', [new TransactionOutput(1, new Script(Buffer::hex("5120070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7")))]], + ]; + } + + /** + * @dataProvider getEcAdapters + * @throws \Exception + */ + public function testTweakTestCase(EcAdapterInterface $ec) + { + $privFactory = new \BitWasp\Bitcoin\Key\Factory\PrivateKeyFactory($ec); + + $privHex = "f698076154c545857fe7072ecb8df962c965b798b1d2b7640da20db3a6fcdb7d"; + $privKey = $privFactory->fromHexUncompressed($privHex); + $pub = $privKey->getPublicKey(); + $xonly = $pub->asXOnlyPublicKey(); + $multisig1of1 = ScriptFactory::create() + ->opcode(Opcodes::OP_0) + ->data($xonly->getBuffer()) + ->opcode(Opcodes::OP_CHECKSIGADD, Opcodes::OP_1, Opcodes::OP_NUMEQUAL) + ->getScript(); + + $tree = [ + [TAPROOT_LEAF_TAPSCRIPT, $multisig1of1] + ]; + $ret = \BitWasp\Bitcoin\Script\Taproot\taprootConstruct($xonly, $tree); + list ($scriptPubKey, $tweak, $scripts, $control) = $ret; + + $this->assertEquals( + "5120070af82161cbd04c96cd86e7f8c600a9370092b02c3cef2fbd9dcb639b1b84b7", + $scriptPubKey->getHex() + ); + $this->assertEquals( + "85a4787be0566335143ad2d60f72b4db97044236515db7ffd733b8f6e10efe64", + $tweak->getHex() + ); + $this->assertEquals( + $multisig1of1->getHex(), + $scripts[0]->getHex() + ); + $this->assertEquals( + $multisig1of1->getHex(), + $scripts[0]->getHex() + ); + $this->assertEquals( + "c0b24dbf3e21d269c0da6e5c1da77c8b4041b9ae85aa1747d2db7f9653aa93ed99", + bin2hex($control[0]) + ); + } + + /** + * @param array $ecAdapterFixtures - array> + * @return array - array> + */ + public function getConsensusAdapters(array $ecAdapterFixtures): array + { + $adapters = []; + foreach ($ecAdapterFixtures as $ecAdapterFixture) { + list ($ecAdapter) = $ecAdapterFixture; + $adapters[] = [new NativeConsensus($ecAdapter)]; + } + + return $adapters; + } +} \ No newline at end of file