diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..540cee0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/tests export-ignore +/build export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1c004b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea/ +/vendor/ +/build/ +composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4dc68c6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47d99df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Roman Zaycev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..870c571 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +Olifanton PHP utils library +--- + +PHP port of [`tonweb-utils`](https://github.com/toncenter/tonweb/tree/master/src/utils) JS library + +`⚠️ This project is under active development!` + +## Install + +```bash +composer require olifanton/utils +``` + +## Tests + +```bash +composer run test +``` + +# License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aa745c1 --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "name": "olifanton/utils", + "version": "0.1.0", + "description": "Olifanton utils library", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/olifanton/utils", + "keywords": [ + "ton", + "blockchain", + "the open network", + "address", + "coins", + "olifanton" + ], + "autoload": { + "psr-4": { + "Olifanton\\Utils\\": "src/Olifanton/Utils/" + } + }, + "autoload-dev": { + "psr-4": { + "Olifanton\\Utils\\Tests\\": "tests/Olifanton/Utils/Tests/" + } + }, + "authors": [ + { + "name": "Roman Zaycev", + "email": "box@romanzaycev.ru", + "role": "Developer" + } + ], + "minimum-stability": "dev", + "require": { + "php": ">=8.1", + "ext-mbstring": "*", + "brick/math": "dev-master", + "ajf/typed-arrays": "dev-master" + }, + "suggest": { + "ext-bcmath": "*", + "ext-openssl": "*", + "ext-sodium": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "scripts": { + "test": "XDEBUG_MODE=coverage phpunit" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f920eb8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + tests + + + + + + src + + + + + + + + diff --git a/src/Olifanton/Utils/Address.php b/src/Olifanton/Utils/Address.php new file mode 100644 index 0000000..4f9868f --- /dev/null +++ b/src/Olifanton/Utils/Address.php @@ -0,0 +1,223 @@ +wc = $anyForm->wc; + $this->hashPart = $anyForm->hashPart; + $this->isTestOnly = $anyForm->isTestOnly; + $this->isUserFriendly = $anyForm->isUserFriendly; + $this->isBounceable = $anyForm->isBounceable; + $this->isUrlSafe = $anyForm->isUrlSafe; + return; + } + + if (strpos($anyForm, "-") > 0 || strpos($anyForm, "_") > 0) { + $this->isUrlSafe = true; + $anyForm = str_replace(["-", "_"], ["+", '/'], $anyForm); + } else { + $this->isUrlSafe = false; + } + + if (str_contains($anyForm, ":")) { + $chunks = explode(":", $anyForm); + + if (count($chunks) !== 2) { + throw new InvalidArgumentException("Invalid address: " . $anyForm); + } + + $wc = (int)$chunks[0]; + + if ($wc !== 0 && $wc !== -1) { + throw new InvalidArgumentException('Invalid address wc: ' . $anyForm); + } + + $hex = $chunks[1]; + + if (strlen($hex) !== 64) { + throw new InvalidArgumentException("Invalid address hex: " . $anyForm); + } + + $this->isUserFriendly = false; + $this->wc = $wc; + $this->hashPart = Bytes::hexStringToBytes($hex); + $this->isTestOnly = false; + $this->isBounceable = false; + } else { + $parseResult = self::parseFriendlyAddress($anyForm); + + $this->isUserFriendly = true; + $this->wc = $parseResult['workchain']; + $this->hashPart = $parseResult['hashPart']; + $this->isTestOnly = $parseResult['isTestOnly']; + $this->isBounceable = $parseResult['isBounceable']; + } + } + + public function toString(?bool $isUserFriendly = null, + ?bool $isUrlSafe = null, + ?bool $isBounceable = null, + ?bool $isTestOnly = null): string + { + $isUserFriendly = ($isUserFriendly === null) ? $this->isUserFriendly : $isUserFriendly; + $isUrlSafe = ($isUrlSafe === null) ? $this->isUrlSafe : $isUrlSafe; + $isBounceable = ($isBounceable === null) ? $this->isBounceable : $isBounceable; + $isTestOnly = ($isTestOnly === null) ? $this->isTestOnly : $isTestOnly; + + if (!$isUserFriendly) { + return $this->wc . ":" . Bytes::bytesToHexString($this->hashPart); + } + + $tag = $isBounceable ? self::BOUNCEABLE_TAG : self::NON_BOUNCEABLE_TAG; + + if ($isTestOnly) { + $tag |= self::TEST_FLAG; + } + + $addr = new Uint8Array(34); + $addr[0] = $tag; + $addr[1] = $this->wc; + $addr->set($this->hashPart, 2); + + $addressWithChecksum = new Uint8Array(36); + $addressWithChecksum->set($addr); + $addressWithChecksum->set(Checksum::crc16($addr), 34); + $addressBase64 = base64_encode(Bytes::arrayToBytes($addressWithChecksum)); + + if ($isUrlSafe) { + $addressBase64 = str_replace(['+', '/'], ["-", '_'], $addressBase64); + } + + return $addressBase64; + } + + public function getWorkchain(): int + { + return $this->wc; + } + + public function getHashPart(): Uint8Array + { + return Bytes::arraySlice($this->hashPart, 0, 32); + } + + public function isTestOnly(): bool + { + return $this->isTestOnly; + } + + public function isUserFriendly(): bool + { + return $this->isUserFriendly; + } + + public function isBounceable(): bool + { + return $this->isBounceable; + } + + public function isUrlSafe(): bool + { + return $this->isUrlSafe; + } + + public function __toString(): string + { + return $this->toString(); + } + + public static function isValid(string | Address $address): bool + { + try { + new Address($address); + + return true; + } catch (\Throwable $e) { + return false; + } + } + + #[ArrayShape([ + 'isTestOnly' => "bool", + 'isBounceable' => "bool", + 'workchain' => "int", + 'hashPart' => "mixed", + ])] + private static function parseFriendlyAddress(string $addressString): array + { + if (strlen($addressString) !== 48) { + throw new InvalidArgumentException("User-friendly address should contain strictly 48 characters"); + } + + $data = Bytes::stringToBytes(base64_decode($addressString)); + + if ($data->length !== 36) { + throw new InvalidArgumentException("Unknown address type: byte length is not equal to 36"); + } + + $addr = Bytes::arraySlice($data, 0, 34); + $crc = Bytes::arraySlice($data, 34, 36); + $checkCrc = Checksum::crc16($addr); + + if (!Bytes::compareBytes($crc, $checkCrc)) { + throw new InvalidArgumentException("Address CRC16-checksum error"); + } + + $tag = $addr[0]; + $isTestOnly = false; + + if ($tag & self::TEST_FLAG) { + $isTestOnly = true; + $tag ^= self::TEST_FLAG; + } + + if (($tag !== self::BOUNCEABLE_TAG) && ($tag !== self::NON_BOUNCEABLE_TAG)) { + throw new InvalidArgumentException("Unknown address tag"); + } + + $isBounceable = $tag === self::BOUNCEABLE_TAG; + + if ($addr[1] === 0xff) { + $workchain = -1; + } else { + $workchain = $addr[1]; + } + + if ($workchain !== 0 && $workchain !== -1) { + throw new InvalidArgumentException("Invalid address workchain: " . $workchain); + } + + $hashPart = Bytes::arraySlice($addr, 2, 34); + + return [ + 'isTestOnly' => $isTestOnly, + 'isBounceable' => $isBounceable, + 'workchain' => $workchain, + 'hashPart' => $hashPart, + ]; + } +} diff --git a/src/Olifanton/Utils/Bytes.php b/src/Olifanton/Utils/Bytes.php new file mode 100644 index 0000000..517dce6 --- /dev/null +++ b/src/Olifanton/Utils/Bytes.php @@ -0,0 +1,162 @@ +|null + */ + private static ?array $base64abcMap = null; + + public static final function readNBytesUIntFromArray(int $n, Uint8Array $uint8Array): int + { + $res = 0; + + for ($i = 0; $i < $n; $i++) { + $res *= 256; + $res += $uint8Array[$i]; + } + + return $res; + } + + public static final function compareBytes(Uint8Array $a, Uint8Array $b): bool + { + return self::arrayToBytes($a) === self::arrayToBytes($b); // @TODO: RAM using optimization + } + + public static function arraySlice(Uint8Array $bytes, int $start, int $end): Uint8Array + { + $result = new Uint8Array($end - $start); + $j = 0; + + for ($i = $start; $i < $end; $i++) { + $result[$j] = $bytes[$i]; + $j++; + } + + return $result; + } + + public static final function concatBytes(Uint8Array $a, Uint8Array $b): Uint8Array + { + $c = new Uint8Array($a->length + $b->length); + $i = 0; + + for ($j = 0; $j < $a->length; $j++) { + $c[$i] = $a[$j]; + $i++; + } + + for ($j = 0; $j < $b->length; $j++) { + $c[$i] = $b[$j]; + $i++; + } + + return $c; + } + + public static final function stringToBytes(string $str, int $size = 1): Uint8Array + { + $buf = null; + $bufView = null; + + if ($size === 1) { + $buf = new ArrayBuffer(strlen($str)); + $bufView = new Uint8Array($buf); + } + + if ($size === 2) { + $buf = new ArrayBuffer(strlen($str) * 2); + $bufView = new Uint16Array($buf); + } + + if ($size === 4) { + $buf = new ArrayBuffer(strlen($str) * 4); + $bufView = new Uint32Array($buf); + } + + if ($buf === null) { + throw new InvalidArgumentException("Unsupported size: ${size}"); + } + + for ($i = 0, $strLen = strlen($str); $i < $strLen; $i++) { + $bufView[$i] = ord($str[$i]); + } + + return new Uint8Array($bufView); + } + + public static final function hexStringToBytes(string $str): Uint8Array + { + $str = mb_strtolower($str); + $length2 = strlen($str); + + if ($length2 % 2 !== 0) { + throw new InvalidArgumentException("Hex string must have length a multiple of 2"); + } + + $length = $length2 / 2; + $result = new Uint8Array($length); + + for ($i = 0; $i < $length; $i++) { + $b = substr($str, $i * 2, 2); + $result[$i] = hexdec($b); + } + + return $result; + } + + public static final function bytesToHexString(Uint8Array $bytes): string + { + $result = []; + + for ($i = 0; $i < $bytes->length; $i++) { + $result[] = str_pad(dechex($bytes[$i]), 2, "0", STR_PAD_LEFT); + } + + return implode("", $result); + } + + public static final function bytesToArray(string $bytes): Uint8Array + { + $arr = new Uint8Array(strlen($bytes)); + + foreach (str_split($bytes) as $i => $byte) { + $arr->offsetSet($i, unpack("C", $byte)[1]); + } + + return $arr; + } + + public static final function arrayToBytes(Uint8Array $arr): string + { + return AjfByteReader::getBytes($arr->buffer); + } + + public static function bytesToBase64(Uint8Array $bytes): string + { + return base64_encode(AjfByteReader::getBytes($bytes->buffer)); + } + + public static final function base64ToBytes(string $base64): Uint8Array + { + $binaryString = base64_decode($base64); + $length = strlen($binaryString); + $bytes = new Uint8Array($length); + + for ($i = 0; $i < $length; $i++) { + $bytes[$i] = ord($binaryString[$i]); + } + + return $bytes; + } +} diff --git a/src/Olifanton/Utils/Checksum.php b/src/Olifanton/Utils/Checksum.php new file mode 100644 index 0000000..1b61a8c --- /dev/null +++ b/src/Olifanton/Utils/Checksum.php @@ -0,0 +1,78 @@ +> 24; + $arr[1] = $intCrc >> 16; + $arr[2] = $intCrc >> 8; + $arr[3] = $intCrc; + + $tmpArray = []; + + for ($i = 0; $i < 4; $i++) { + $tmpArray[] = $arr[$i]; + } + + return new Uint8Array(array_reverse($tmpArray)); + } + + public static final function crc16(Uint8Array $bytes): Uint8Array + { + $reg = 0; + $message = new Uint8Array($bytes->length + 2); + $message->set($bytes); + + for ($i = 0; $i < $message->length; $i++) { + $byte = $message[$i]; + $mask = 0x80; + + while ($mask > 0) { + $reg <<= 1; + + if ($byte & $mask) { + $reg += 1; + } + + $mask >>= 1; + + if ($reg > 0xffff) { + $reg &= 0xffff; + $reg ^= self::POLY_16; + } + } + } + + return new Uint8Array([(int)floor($reg / 256), $reg % 256]); + } + + private static function crc32cInternal(int $crc, Uint8Array $bytes): int + { + $crc ^= 0xffffffff; + + for ($n = 0; $n < $bytes->length; $n++) { + $crc ^= $bytes[$n]; + + for ($i = 0; $i < 8; $i++) { + $crc = $crc & 1 ? (self::rrr($crc, 1)) ^ self::POLY_32 : self::rrr($crc, 1); + } + } + + return $crc ^ 0xffffffff; + } + + private static function rrr(int $v, int $n): int + { + return ($v & 0xffffffff) >> ($n & 0x1f); + } +} diff --git a/src/Olifanton/Utils/Crypto.php b/src/Olifanton/Utils/Crypto.php new file mode 100644 index 0000000..fe0418f --- /dev/null +++ b/src/Olifanton/Utils/Crypto.php @@ -0,0 +1,74 @@ +digestSha256($bytes); + } + + /** + * @throws CryptoException + */ + public static final function keyPairFromSeed(Uint8Array $seed): KeyPair + { + return self::ensureKeyPairProvider()->keyPairFromSeed($seed); + } + + /** + * @throws CryptoException + */ + public static final function newKeyPair(): KeyPair + { + return self::ensureKeyPairProvider()->newKeyPair(); + } + + /** + * @throws CryptoException + */ + public static final function newSeed(): Uint8Array + { + return self::ensureKeyPairProvider()->newSeed(); + } + + private static function ensureKeyPairProvider(): KeyPairProvider + { + if (!self::$keyPairProvider) { + self::$keyPairProvider = new DefaultProvider(); + } + + return self::$keyPairProvider; + } + + private static function ensureDigestProvider(): DigestProvider + { + if (!self::$digestProvider) { + self::$digestProvider = new DefaultProvider(); + } + + return self::$digestProvider; + } +} diff --git a/src/Olifanton/Utils/CryptoProviders/DefaultProvider.php b/src/Olifanton/Utils/CryptoProviders/DefaultProvider.php new file mode 100644 index 0000000..52caa45 --- /dev/null +++ b/src/Olifanton/Utils/CryptoProviders/DefaultProvider.php @@ -0,0 +1,120 @@ +keyPairFromSodium($keyPair); + // @codeCoverageIgnoreStart + } catch (\SodiumException $e) { + throw new CryptoException($e->getMessage(), $e->getCode(), $e); + } + // @codeCoverageIgnoreEnd + } + + /** + * @inheritDoc + */ + public function newKeyPair(): KeyPair + { + self::checkExt("sodium"); + + try { + return $this->keyPairFromSodium(sodium_crypto_sign_keypair()); + // @codeCoverageIgnoreStart + } catch (\SodiumException $e) { + throw new CryptoException($e->getMessage(), $e->getCode(), $e); + } + // @codeCoverageIgnoreEnd + } + + /** + * @inheritDoc + */ + public function newSeed(): Uint8Array + { + self::checkExt("sodium"); + + try { + $keyPair = sodium_crypto_sign_keypair(); + $secretKey = sodium_crypto_sign_secretkey($keyPair); + + return Bytes::bytesToArray(substr($secretKey, 0, 32)); + // @codeCoverageIgnoreStart + } catch (\SodiumException $e) { + throw new CryptoException($e->getMessage(), $e->getCode(), $e); + } + // @codeCoverageIgnoreEnd + } + + + /** + * @throws \SodiumException + */ + private function keyPairFromSodium(string $sodiumKP): KeyPair + { + return new KeyPair( + Bytes::bytesToArray(sodium_crypto_sign_publickey($sodiumKP)), + Bytes::bytesToArray(sodium_crypto_sign_secretkey($sodiumKP)), + ); + } + + /** + * @throws CryptoException + */ + private function checkExt(string $ext): void + { + if (in_array($ext, $this->ext)) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } + + if (!extension_loaded($ext)) { + // @codeCoverageIgnoreStart + throw new CryptoException("Missing `" . $ext . "` extension"); + // @codeCoverageIgnoreEnd + } + + $this->ext[] = $ext; + } +} diff --git a/src/Olifanton/Utils/DigestProvider.php b/src/Olifanton/Utils/DigestProvider.php new file mode 100644 index 0000000..d372edc --- /dev/null +++ b/src/Olifanton/Utils/DigestProvider.php @@ -0,0 +1,14 @@ + '0', + 'wei' => '1', + 'kwei' => '1000', + 'Kwei' => '1000', + 'babbage' => '1000', + 'femtoether' => '1000', + 'mwei' => '1000000', + 'Mwei' => '1000000', + 'lovelace' => '1000000', + 'picoether' => '1000000', + 'gwei' => '1000000000', + 'Gwei' => '1000000000', + 'shannon' => '1000000000', + 'nanoether' => '1000000000', + 'nano' => '1000000000', + 'szabo' => '1000000000000', + 'microether' => '1000000000000', + 'micro' => '1000000000000', + 'finney' => '1000000000000000', + 'milliether' => '1000000000000000', + 'milli' => '1000000000000000', + 'ether' => '1000000000000000000', + 'kether' => '1000000000000000000000', + 'grand' => '1000000000000000000000', + 'mether' => '1000000000000000000000000', + 'gether' => '1000000000000000000000000000', + 'tether' => '1000000000000000000000000000000', + ]; + + public static final function toNano(BigNumber|string|int|float $amount): BigInteger + { + return self::toWei(BigNumber::of($amount), 'gwei')->toBigInteger(); + } + + public static final function fromNano(BigNumber|string|int $amount): BigNumber + { + return self::fromWei(BigNumber::of($amount)->toScale(9), 'gwei'); + } + + private static function toWei(BigNumber $bn, string $unit): BigNumber + { + if (!isset(self::UNITS[$unit])) { + throw new InvalidArgumentException('toWei doesn\'t support ' . $unit . ' unit.'); + } + + return $bn->multipliedBy(BigNumber::of(self::UNITS[$unit])); + } + + private static function fromWei(BigNumber $bn, string $unit): BigNumber + { + if (!isset(self::UNITS[$unit])) { + throw new InvalidArgumentException('fromWei doesn\'t support ' . $unit . ' unit.'); + } + + $bnt = BigNumber::of(self::UNITS[$unit]); + + return self::fixScale($bn->dividedBy($bnt)); + } + + private static function fixScale(BigNumber $bn): BigNumber + { + // Fix scale + $strValue = (string)$bn; + $isDrop = true; + $dropZeroCount = array_reduce( + array_reverse(str_split($strValue)), + function (int $carry, string $n) use (&$isDrop) { + if ($isDrop) { + if ($n !== "0") { + $isDrop = false; + + return $carry; + } + + return $carry + 1; + } + + return $carry; + }, + 0 + ); + + return $bn->toScale(9 - $dropZeroCount); + } +} diff --git a/tests/Olifanton/Utils/Tests/AddressTest.php b/tests/Olifanton/Utils/Tests/AddressTest.php new file mode 100644 index 0000000..44e48f4 --- /dev/null +++ b/tests/Olifanton/Utils/Tests/AddressTest.php @@ -0,0 +1,99 @@ +assertTrue(Address::isValid("EQD__________________________________________0vo")); + $this->assertTrue(Address::isValid("EQBvI0aFLnw2QbZgjMPCLRdtRHxhUyinQudg6sdiohIwg5jL")); + $this->assertTrue(Address::isValid(new Address("EQBvI0aFLnw2QbZgjMPCLRdtRHxhUyinQudg6sdiohIwg5jL"))); + $this->assertTrue(Address::isValid("-1:fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db29232260")); + $this->assertTrue(Address::isValid("kf/8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSMiYIny")); + $this->assertTrue(Address::isValid("kf_8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSMiYIny")); + } + + public function testIsValidFail(): void + { + // Length + $this->assertFalse(Address::isValid("EQD0vo")); + + // Checksum + $this->assertFalse(Address::isValid("EQD__________________________________________0v0")); + $this->assertFalse(Address::isValid("zQBvI0aFLnw2QbZgjMPCLRdtRHxhUyinQudg6sdiohIwg5jL")); + + // Unknown workchain + $this->assertFalse(Address::isValid("2:fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db29232260")); + + // Format + $this->assertFalse(Address::isValid("-1:fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db29232260:")); + + // Byte length + $this->assertFalse(Address::isValid("-1:fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db2923226060")); + $this->assertFalse(Address::isValid("kf_8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSQMiYIny")); + } + + public function testToString(): void + { + $address = new Address("-1:fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db29232260"); + + // hex + $this->assertEquals( + "-1:fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db29232260", + $address->toString(isUserFriendly: false) + ); + + // User-friendly + $this->assertEquals( + "Uf/8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15+KsQHFLbKSMiYG+9", + $address->toString(isUserFriendly: true) + ); + + // User-friendly and URL safe + $this->assertEquals( + "Uf_8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSMiYG-9", + $address->toString(isUserFriendly: true, isUrlSafe: true) + ); + + // Bounceable + $this->assertEquals( + "Ef_8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSMiYDJ4", + $address->toString(isUserFriendly: true, isUrlSafe: true, isBounceable: true), + ); + + // User-friendly, URL safe, Bounceable and test only + $this->assertEquals( + "kf_8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSMiYIny", + $address->toString(true, true, true, true), + ); + } + + public function testStringCast(): void + { + $address = new Address("EQBvI0aFLnw2QbZgjMPCLRdtRHxhUyinQudg6sdiohIwg5jL"); + $this->assertEquals( + "EQBvI0aFLnw2QbZgjMPCLRdtRHxhUyinQudg6sdiohIwg5jL", + (string)$address, + ); + } + + public function testGetters(): void + { + $address = new Address("kf_8uRo6OBbQ97jCx2EIuKm8Wmt6Vb15-KsQHFLbKSMiYIny"); + + $this->assertTrue($address->isUserFriendly()); + $this->assertTrue($address->isBounceable()); + $this->assertTrue($address->isTestOnly()); + $this->assertTrue($address->isUrlSafe()); + $this->assertEquals(-1, $address->getWorkchain()); + $this->assertEquals( + "fcb91a3a3816d0f7b8c2c76108b8a9bc5a6b7a55bd79f8ab101c52db29232260", + Bytes::bytesToHexString($address->getHashPart()), + ); + } +} diff --git a/tests/Olifanton/Utils/Tests/BytesTest.php b/tests/Olifanton/Utils/Tests/BytesTest.php new file mode 100644 index 0000000..2924df5 --- /dev/null +++ b/tests/Olifanton/Utils/Tests/BytesTest.php @@ -0,0 +1,43 @@ +assertEquals(0, Bytes::readNBytesUIntFromArray(0, $stub)); + $this->assertEquals(1, Bytes::readNBytesUIntFromArray(1, $stub)); + $this->assertEquals(258, Bytes::readNBytesUIntFromArray(2, $stub)); + $this->assertEquals(66051, Bytes::readNBytesUIntFromArray(3, $stub)); + $this->assertEquals(16909060, Bytes::readNBytesUIntFromArray(4, $stub)); + $this->assertEquals(4328719365, Bytes::readNBytesUIntFromArray(5, $stub)); + } + + public function testConcatBytes(): void + { + $a0 = new Uint8Array([0, 1]); + $a1 = new Uint8Array([2, 3]); + + $result = Bytes::concatBytes($a0, $a1); + $this->assertEquals("00010203", Bytes::bytesToHexString($result)); + } + + public function testBytesToBase64(): void + { + $stub = new Uint8Array([0, 1, 2]); + $this->assertEquals("AAEC", Bytes::bytesToBase64($stub)); + } + + public function testBase64ToBytes(): void + { + $stub = new Uint8Array([0, 1, 2]); + $this->assertTrue(Bytes::compareBytes($stub, Bytes::base64ToBytes("AAEC"))); + } +} diff --git a/tests/Olifanton/Utils/Tests/ChecksumTest.php b/tests/Olifanton/Utils/Tests/ChecksumTest.php new file mode 100644 index 0000000..1347373 --- /dev/null +++ b/tests/Olifanton/Utils/Tests/ChecksumTest.php @@ -0,0 +1,53 @@ +assertEquals( + "6131", + Bytes::bytesToHexString(Checksum::crc16($stub)), + ); + + $stub = new Uint8Array([3, 2, 1]); + $this->assertEquals( + "2f13", + Bytes::bytesToHexString(Checksum::crc16($stub)), + ); + + $stub = new Uint8Array([]); + $this->assertEquals( + "0000", + Bytes::bytesToHexString(Checksum::crc16($stub)), + ); + } + + public function testCrc32c(): void + { + $stub = new Uint8Array([1, 2, 3]); + $this->assertEquals( + "1ef230f1", + Bytes::bytesToHexString(Checksum::crc32c($stub)), + ); + + $stub = new Uint8Array([3, 2, 1]); + $this->assertEquals( + "e4d0645f", + Bytes::bytesToHexString(Checksum::crc32c($stub)), + ); + + $stub = new Uint8Array([]); + $this->assertEquals( + "00000000", + Bytes::bytesToHexString(Checksum::crc32c($stub)), + ); + } +} diff --git a/tests/Olifanton/Utils/Tests/CryptoTest.php b/tests/Olifanton/Utils/Tests/CryptoTest.php new file mode 100644 index 0000000..93be0cb --- /dev/null +++ b/tests/Olifanton/Utils/Tests/CryptoTest.php @@ -0,0 +1,129 @@ +assertEquals( + "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81", + Bytes::bytesToHexString(Crypto::sha256($stub)), + ); + } + + /** + * @throws \Olifanton\Utils\Exceptions\CryptoException + */ + public function testSha256EmptyArray(): void + { + $stub = new Uint8Array([]); + $this->assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Bytes::bytesToHexString(Crypto::sha256($stub)), + ); + } + + /** + * @throws \Olifanton\Utils\Exceptions\CryptoException + */ + public function testKeyPairFromSeedZeroFill(): void + { + $seed = new Uint8Array(array_fill(0, 32, 0)); + $keyPair = Crypto::keyPairFromSeed($seed); + $this->assertEquals( + "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29", + Bytes::bytesToHexString($keyPair->publicKey), + ); + $this->assertEquals( + "00000000000000000000000000000000000000000000000000000000000000003b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29", + Bytes::bytesToHexString($keyPair->secretKey), + ); + } + + /** + * @throws \Olifanton\Utils\Exceptions\CryptoException + */ + public function testKeyPairFromSeedConstant(): void + { + $seed = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]); + $keyPair = Crypto::keyPairFromSeed($seed); + $this->assertEquals( + "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", + Bytes::bytesToHexString($keyPair->publicKey), + ); + $this->assertEquals( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", + Bytes::bytesToHexString($keyPair->secretKey), + ); + } + + /** + * @throws \Olifanton\Utils\Exceptions\CryptoException + */ + public function testNewKeyPair(): void + { + $keyPair = Crypto::newKeyPair(); + + $this->assertEquals(32, strlen(Bytes::arrayToBytes($keyPair->publicKey))); + $this->assertEquals(64, strlen(Bytes::bytesToHexString($keyPair->publicKey))); + + $this->assertEquals(64, strlen(Bytes::arrayToBytes($keyPair->secretKey))); + $this->assertEquals(128, strlen(Bytes::bytesToHexString($keyPair->secretKey))); + } + + /** + * @throws \Olifanton\Utils\Exceptions\CryptoException + */ + public function testNewSeed(): void + { + $seed = Crypto::newSeed(); + + $this->assertEquals(32, strlen(Bytes::arrayToBytes($seed))); + $this->assertEquals(64, strlen(Bytes::bytesToHexString($seed))); + } + + /** + * @throws \Olifanton\Utils\Exceptions\CryptoException + */ + public function testSetProviders(): void + { + $stub = new CryptoProviderStub(); + $stubEmpty32 = new Uint8Array(array_fill(0, 32, 0)); + $stubEmpty64 = Bytes::concatBytes($stubEmpty32, $stubEmpty32); + + Crypto::setDigestProvider($stub); + Crypto::setKeyPairProvider($stub); + + $this->assertTrue(Bytes::compareBytes($stubEmpty32, Crypto::sha256(new Uint8Array([])))); + $this->assertTrue(Bytes::compareBytes($stubEmpty32, Crypto::newSeed())); + $this->assertTrue(Bytes::compareBytes($stubEmpty32, Crypto::newKeyPair()->publicKey)); + $this->assertTrue(Bytes::compareBytes($stubEmpty64, Crypto::newKeyPair()->secretKey)); + $this->assertTrue(Bytes::compareBytes($stubEmpty32, Crypto::keyPairFromSeed($stubEmpty32)->publicKey)); + $this->assertTrue(Bytes::compareBytes($stubEmpty64, Crypto::keyPairFromSeed($stubEmpty32)->secretKey)); + } +} diff --git a/tests/Olifanton/Utils/Tests/Stubs/CryptoProviderStub.php b/tests/Olifanton/Utils/Tests/Stubs/CryptoProviderStub.php new file mode 100644 index 0000000..6a8df9d --- /dev/null +++ b/tests/Olifanton/Utils/Tests/Stubs/CryptoProviderStub.php @@ -0,0 +1,37 @@ +assertEquals("500000000", Units::toNano("0.5")->toBase(10)); + $this->assertEquals("1000000000", Units::toNano(1)->toBase(10)); + $this->assertEquals("500000000", Units::toNano(0.5)->toBase(10)); + $this->assertEquals("10000000000", Units::toNano(10)->toBase(10)); + $this->assertEquals("10100000000", Units::toNano("10.1")->toBase(10)); + $this->assertEquals("123012345678", Units::toNano("123.012345678")->toBase(10)); + $this->assertEquals("123000012345678", Units::toNano("123000.012345678")->toBase(10)); + } + + public function testToNanoTooManyPrecision(): void + { + $this->expectException(RoundingNecessaryException::class); + Units::toNano("123000.0123456789"); + } + + public function testFromNano(): void + { + $this->assertEquals("1", (string)Units::fromNano("1000000000")); + $this->assertEquals("0.5", (string)Units::fromNano("500000000")); + $this->assertEquals("0.000000001", (string)Units::fromNano("1")); + $this->assertEquals("0.000000001", (string)Units::fromNano(1)); + $this->assertEquals("0.00000002", (string)Units::fromNano(20)); + } + + public function testFromNanoDecimal(): void + { + $this->expectException(RoundingNecessaryException::class); + Units::fromNano("20.1"); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..dfbb2ec --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ +