Skip to content

Commit 7cb682a

Browse files
committed
fix utxo pubkey decompress
1 parent 0c400e2 commit 7cb682a

File tree

8 files changed

+310
-6
lines changed

8 files changed

+310
-6
lines changed

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
],
1616
"require": {
1717
"php": "^7.1",
18+
"ext-gmp": "^7.1",
1819
"ext-leveldb": "^0.2.1",
1920
"stephenhill/base58": "^1.1",
2021
"bitwasp/bech32": "^0.0.1",
21-
"andkom/php-bcdatastream": "^1.1"
22+
"andkom/php-bcdatastream": "^1.1",
23+
"mdanter/ecc": "^0.5.0"
2224
},
2325
"require-dev": {
2426
"phpunit/phpunit": ">=5.0"

src/Header.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,6 @@ public function getHash(): string
8181
{
8282
$stream = new Writer();
8383
$this->serialize($stream);
84-
return Utils::hash($stream->getBuffer(), true);
84+
return Utils::hash256($stream->getBuffer(), true);
8585
}
8686
}

src/PublicKey.php

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<?php
2+
3+
namespace AndKom\Bitcoin\Blockchain;
4+
5+
use Mdanter\Ecc\EccFactory;
6+
7+
/**
8+
* Class PublicKey
9+
* @package AndKom\Bitcoin\Blockchain
10+
*/
11+
class PublicKey
12+
{
13+
const PREFIX_UNCOMPRESSED = "\x04";
14+
const PREFIX_COMPRESSED_EVEN = "\x02";
15+
const PREFIX_COMPRESSED_ODD = "\x03";
16+
17+
const LENGTH_UNCOMPRESSED = 65;
18+
const LENGTH_COMPRESSED = 33;
19+
20+
/**
21+
* @var \GMP
22+
*/
23+
protected $x;
24+
25+
/**
26+
* @var \GMP
27+
*/
28+
protected $y;
29+
30+
/**
31+
* @var bool
32+
*/
33+
protected $wasOdd;
34+
35+
/**
36+
* PublicKey constructor.
37+
* @param \GMP $x
38+
* @param \GMP|null $y
39+
* @param bool $wasOdd
40+
*/
41+
public function __construct(\GMP $x, \GMP $y = null, bool $wasOdd = false)
42+
{
43+
$this->x = $x;
44+
$this->y = $y;
45+
$this->wasOdd = $wasOdd;
46+
}
47+
48+
/**
49+
* @return \GMP
50+
*/
51+
public function getX(): \GMP
52+
{
53+
return $this->x;
54+
}
55+
56+
/**
57+
* @return \GMP
58+
* @throws Exception
59+
*/
60+
public function getY(): \GMP
61+
{
62+
if ($this->isCompressed()) {
63+
throw new Exception("Compressed public key doesn't have Y coordinate.");
64+
}
65+
66+
return $this->y;
67+
}
68+
69+
/**
70+
* @return bool
71+
*/
72+
public function wasOdd(): bool
73+
{
74+
return $this->wasOdd;
75+
}
76+
77+
/**
78+
* @return bool
79+
*/
80+
public function isCompressed(): bool
81+
{
82+
return is_null($this->y);
83+
}
84+
85+
/**
86+
* @return PublicKey
87+
* @throws Exception
88+
*/
89+
public function compress(): self
90+
{
91+
if ($this->isCompressed()) {
92+
throw new Exception('Public key is already compressed.');
93+
}
94+
95+
$wasOdd = \gmp_cmp(
96+
\gmp_mod($this->y, \gmp_init(2)),
97+
\gmp_init(0)
98+
) !== 0;
99+
100+
return new static($this->x, null, $wasOdd);
101+
}
102+
103+
/**
104+
* @return PublicKey
105+
* @throws Exception
106+
*/
107+
public function decompress(): self
108+
{
109+
if (!$this->isCompressed()) {
110+
throw new Exception('Public key is already decompressed.');
111+
}
112+
113+
$curve = EccFactory::getSecgCurves()->generator256k1()->getCurve();
114+
$y = $curve->recoverYfromX($this->wasOdd, $this->x);
115+
116+
return new static($this->x, $y);
117+
}
118+
119+
/**
120+
* @return \Mdanter\Ecc\Crypto\Key\PublicKey
121+
* @throws Exception
122+
*/
123+
public function getEccPublicKey(): \Mdanter\Ecc\Crypto\Key\PublicKey
124+
{
125+
$adapter = EccFactory::getAdapter();
126+
$generator = EccFactory::getSecgCurves()->generator256k1();
127+
$curve = $generator->getCurve();
128+
129+
if ($this->isCompressed()) {
130+
$key = $this->decompress();
131+
} else {
132+
$key = $this;
133+
}
134+
135+
$point = $curve->getPoint($key->getX(), $this->getY());
136+
137+
return new \Mdanter\Ecc\Crypto\Key\PublicKey($adapter, $generator, $point);
138+
}
139+
140+
/**
141+
* @param string $data
142+
* @return PublicKey
143+
* @throws Exception
144+
*/
145+
static public function parse(string $data): self
146+
{
147+
$length = strlen($data);
148+
149+
if ($length == static::LENGTH_COMPRESSED) {
150+
$prefix = substr($data, 0, 1);
151+
152+
if ($prefix != static::PREFIX_COMPRESSED_ODD && $prefix != static::PREFIX_COMPRESSED_EVEN) {
153+
throw new Exception('Invalid compressed public key prefix.');
154+
}
155+
156+
$x = \gmp_init(bin2hex(substr($data, 1, 32)), 16);
157+
$y = null;
158+
} elseif ($length == static::LENGTH_UNCOMPRESSED) {
159+
$prefix = substr($data, 0, 1);
160+
161+
if ($prefix != static::PREFIX_UNCOMPRESSED) {
162+
throw new Exception('Invalid uncompressed public key prefix.');
163+
}
164+
165+
$x = \gmp_init(bin2hex(substr($data, 1, 32)), 16);
166+
$y = \gmp_init(bin2hex(substr($data, 33, 32)), 16);
167+
} else {
168+
throw new Exception('Invalid public key size.');
169+
}
170+
171+
return new static($x, $y, $prefix == static::PREFIX_COMPRESSED_ODD);
172+
}
173+
174+
/**
175+
* @return string
176+
*/
177+
public function serialize(): string
178+
{
179+
$x = hex2bin(\gmp_strval($this->x, 16));
180+
181+
if ($this->isCompressed()) {
182+
$prefix = $this->wasOdd ? static::PREFIX_COMPRESSED_ODD : static::PREFIX_COMPRESSED_EVEN;
183+
$y = '';
184+
} else {
185+
$prefix = static::PREFIX_UNCOMPRESSED;
186+
$y = hex2bin(\gmp_strval($this->y, 16));
187+
}
188+
189+
return $prefix . $x . $y;
190+
}
191+
}

src/Transaction.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,6 @@ public function getHash(bool $segWit = false): string
137137
{
138138
$stream = new Writer();
139139
$this->serialize($stream, $segWit);
140-
return Utils::hash($stream->getBuffer(), true);
140+
return Utils::hash256($stream->getBuffer(), true);
141141
}
142142
}

src/UnspentOutput.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,12 @@ public function getAddress(Bitcoin $network = null): string
119119

120120
case 0x02:
121121
case 0x03:
122+
return $addressSerializer->getPayToPubKeyAddress($this->script);
123+
122124
case 0x04:
123125
case 0x05:
124-
return $addressSerializer->getPayToPubKeyAddress($this->script);
126+
$decompressed = PublicKey::parse($this->script)->decompress()->serialize();
127+
return $addressSerializer->getPayToPubKeyAddress($decompressed);
125128
}
126129

127130
// fallback

src/Utils.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Utils
1717
* @param bool $raw
1818
* @return string
1919
*/
20-
static public function hash(string $data, bool $raw = false): string
20+
static public function hash256(string $data, bool $raw = false): string
2121
{
2222
return hash('sha256', hash('sha256', $data, true), $raw);
2323
}
@@ -41,7 +41,7 @@ static public function hash160(string $data, bool $raw = false): string
4141
static public function hash160ToAddress(string $hash160, int $network): string
4242
{
4343
$hash160 = chr($network) . $hash160;
44-
$checksum = substr(static::hash($hash160, true), 0, 4);
44+
$checksum = substr(static::hash256($hash160, true), 0, 4);
4545
$address = $hash160 . $checksum;
4646
return (new Base58())->encode($address);
4747
}

tests/PublicKeyTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AndKom\Bitcoin\Blockchain\Tests;
6+
7+
use AndKom\Bitcoin\Blockchain\PublicKey;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class PublicKeyTest extends TestCase
11+
{
12+
protected $x = '6655feed4d214c261e0a6b554395596f1f1476a77d999560e5a8df9b8a1a3515';
13+
protected $y = '217e88dd05e938efdd71b2cce322bf01da96cd42087b236e8f5043157a9c068e';
14+
15+
public function testCompress()
16+
{
17+
$pk = new PublicKey(\gmp_init($this->x, 16), \gmp_init($this->y, 16));
18+
19+
$this->assertEquals(\gmp_strval($pk->getX(), 16), $this->x);
20+
$this->assertEquals(\gmp_strval($pk->getY(), 16), $this->y);
21+
$this->assertFalse($pk->isCompressed());
22+
23+
$pkc = $pk->compress();
24+
25+
$this->assertTrue($pkc->isCompressed());
26+
$this->assertFalse($pkc->wasOdd());
27+
$this->assertEquals(\gmp_strval($pkc->getX(), 16), $this->x);
28+
}
29+
30+
public function testDecompress()
31+
{
32+
$pkc = new PublicKey(\gmp_init($this->x, 16), null, false);
33+
34+
$pk = $pkc->decompress();
35+
36+
$this->assertFalse($pk->isCompressed());
37+
$this->assertEquals(\gmp_strval($pk->getX(), 16), $this->x);
38+
$this->assertEquals(\gmp_strval($pk->getY(), 16), $this->y);
39+
}
40+
41+
public function testParseUncompressed()
42+
{
43+
$key = "04{$this->x}{$this->y}";
44+
45+
$pk = PublicKey::parse(hex2bin($key));
46+
47+
$this->assertEquals(\gmp_strval($pk->getX(), 16), $this->x);
48+
$this->assertEquals(\gmp_strval($pk->getY(), 16), $this->y);
49+
$this->assertFalse($pk->isCompressed());
50+
}
51+
52+
public function testParseCompressed()
53+
{
54+
$key = "02{$this->x}";
55+
56+
$pk = PublicKey::parse(hex2bin($key));
57+
58+
$this->assertEquals(\gmp_strval($pk->getX(), 16), $this->x);
59+
$this->assertTrue($pk->isCompressed());
60+
}
61+
62+
public function testSerializeUncompressed()
63+
{
64+
$key = "04{$this->x}{$this->y}";
65+
66+
$pk = new PublicKey(\gmp_init($this->x, 16), \gmp_init($this->y, 16));
67+
68+
$this->assertEquals($pk->serialize(), hex2bin($key));
69+
}
70+
71+
public function testSerializeCompressed()
72+
{
73+
$key = "02{$this->x}";
74+
75+
$pk = new PublicKey(\gmp_init($this->x, 16), null, false);
76+
77+
$this->assertEquals($pk->serialize(), hex2bin($key));
78+
}
79+
}

tests/UnspentOutputTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AndKom\Bitcoin\Blockchain\Tests;
6+
7+
use AndKom\Bitcoin\Blockchain\UnspentOutput;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class UnspentOutputTest extends TestCase
11+
{
12+
public function testPayToPubKeyCompressed()
13+
{
14+
$uo = new UnspentOutput();
15+
$uo->type = 0x02;
16+
$uo->script = hex2bin('02633280c0a93b45217059013ddadab8d35b9a858336028fecdff64c6a5e068fad');
17+
18+
$this->assertEquals($uo->getAddress(), '1A8nTwDWzKhV2UNEss6DtDBKuYfJH8TFDG');
19+
}
20+
21+
public function testPayToPubKeyUncompressed()
22+
{
23+
$uo = new UnspentOutput();
24+
$uo->type = 0x04;
25+
$uo->script = hex2bin('02633280c0a93b45217059013ddadab8d35b9a858336028fecdff64c6a5e068fad');
26+
27+
$this->assertEquals($uo->getAddress(), '1PTYXwamXXgQoAhDbmUf98rY2Pg1pYXhin');
28+
}
29+
}

0 commit comments

Comments
 (0)