diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a1f35d..c130f5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '8.4' - extensions: curl, bcmath + extensions: curl coverage: none - uses: ramsey/composer-install@v3 - run: vendor/bin/phpstan analyse --no-progress --error-format=github @@ -69,7 +69,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: curl, bcmath + extensions: curl coverage: none ini-values: error_reporting=-1, display_errors=On - uses: ramsey/composer-install@v3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18bf519..21a688e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ If a fix applies to both lines, open the pull request against `2.x`. A maintaine You need: -- PHP 7.4 or newer with the `curl` and `bcmath` extensions. A current PHP 8.x is recommended so you see deprecation notices. +- PHP 7.4 or newer with the `curl` extension. A current PHP 8.x is recommended so you see deprecation notices. - [Composer](https://getcomposer.org/). - A [BitGo testnet](https://app.bitgo-test.com/) account and access token, if you want to run requests against the API. Never use production tokens or real funds while developing. diff --git a/README.md b/README.md index b568a84..cce6cfe 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,7 @@ BitGoSDK written in PHP. This SDK contains methods for easily interacting with t ## Requirements -- PHP 7.4 or newer with: - - cURL - - BCMath +- PHP 7.4 or newer with the cURL extension ## Installation diff --git a/composer.json b/composer.json index 84505ca..42bdafe 100644 --- a/composer.json +++ b/composer.json @@ -33,8 +33,7 @@ "require": { "php": ">=7.4", - "ext-curl": "*", - "ext-bcmath": "*" + "ext-curl": "*" }, "require-dev": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e6129d5..18908dd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -522,12 +522,6 @@ parameters: count: 1 path: src/BitGoSDK.php - - - message: '#^Method neto737\\BitGoSDK\\BitGoSDK\:\:toBTC\(\) should return float but returns string\.$#' - identifier: return.type - count: 1 - path: src/BitGoSDK.php - - message: '#^Method neto737\\BitGoSDK\\BitGoSDK\:\:unlockSession\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -552,24 +546,6 @@ parameters: count: 1 path: src/BitGoSDK.php - - - message: '#^Parameter \#1 \$left_operand of function bcdiv expects numeric\-string, int given\.$#' - identifier: argument.type - count: 1 - path: src/BitGoSDK.php - - - - message: '#^Parameter \#2 \$right_operand of function bcdiv expects numeric\-string, 100000000 given\.$#' - identifier: argument.type - count: 1 - path: src/BitGoSDK.php - - - - message: '#^Parameter \#2 \$right_operand of function bcmul expects numeric\-string, 100000000 given\.$#' - identifier: argument.type - count: 1 - path: src/BitGoSDK.php - - message: '#^Property neto737\\BitGoSDK\\BitGoSDK\:\:\$APIEndpoint has no type specified\.$#' identifier: missingType.property diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 7b071d3..6461826 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -19,6 +19,10 @@ class BitGoExpress { + // A little over BitGo Express's own 305 second request timeout, so Express reports its timeout first. + const DEFAULT_TIMEOUT = 310; + const DEFAULT_CONNECT_TIMEOUT = 10; + private $APIEndpoint = null; private $AuthAPIEndpoint = null; private $url = null; @@ -27,6 +31,8 @@ class BitGoExpress private $login = false; public $accessToken = null; public $walletId = null; + private int $timeout = self::DEFAULT_TIMEOUT; + private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; /** * BitGoExpress Initialization @@ -45,6 +51,34 @@ public function __construct(string $hostname, int $port, string $coin = Currency $this->APIEndpoint = 'http://' . $this->hostname . ':' . $this->port . '/api/v2/' . $this->coin; } + /** + * Set the maximum time a request may take, connection included + * + * @param int $seconds 0 waits forever + * @throws \InvalidArgumentException + */ + public function setTimeout(int $seconds): void + { + if ($seconds < 0) { + throw new \InvalidArgumentException('Timeout must not be negative.'); + } + $this->timeout = $seconds; + } + + /** + * Set the maximum time to wait for a connection to BitGo Express + * + * @param int $seconds 0 uses cURL's built-in default of 300 seconds + * @throws \InvalidArgumentException + */ + public function setConnectTimeout(int $seconds): void + { + if ($seconds < 0) { + throw new \InvalidArgumentException('Connect timeout must not be negative.'); + } + $this->connectTimeout = $seconds; + } + /** * Get a token for first-party access to the BitGo API. First-party access is only intended for users accessing their own BitGo accounts. * @@ -141,7 +175,7 @@ public function addWallet(string $label, int $m, int $n, array $keys, ?string $e * This API call allows you to create and send cryptocurrency to a destination address. * * @param string $address Recipient address - * @param int $amount Amount to be sent to the recipient + * @param int|string $amount Amount to be sent to the recipient, in base units (e.g. satoshi or wei). Use a string for amounts above PHP_INT_MAX. * @param string $walletPassphrase The passphrase to be used to decrypt the user key on this wallet * @param string $prv The private key in string form if the walletPassphrase is not available * @param int $numBlocks Estimates the approximate fee per kilobyte necessary for a transaction confirmation within 'numBlocks' blocks. @@ -162,12 +196,12 @@ public function addWallet(string $label, int $m, int $n, array $keys, ?string $e * @param string $ledgerSequenceDelta Relative ledger height (in relation to the current ledger) that the transaction should be accepted in, whereafter it will be rejected. * @return array */ - public function sendTransaction(string $address, int $amount, string $walletPassphrase, ?string $prv = null, ?int $numBlocks = null, ?int $feeRate = null, ?string $comment = null, ?array $unspents = null, ?int $minConfirms = null, ?bool $enforceMinConfirmsForChange = null, ?int $targetWalletUnspents = null, ?bool $noSplitChange = null, ?int $minValue = null, ?int $maxValue = null, ?int $gasPrice = null, ?int $gasLimit = null, ?int $sequenceId = null, ?bool $segwit = null, ?int $lastLedgerSequence = null, ?string $ledgerSequenceDelta = null) + public function sendTransaction(string $address, $amount, string $walletPassphrase, ?string $prv = null, ?int $numBlocks = null, ?int $feeRate = null, ?string $comment = null, ?array $unspents = null, ?int $minConfirms = null, ?bool $enforceMinConfirmsForChange = null, ?int $targetWalletUnspents = null, ?bool $noSplitChange = null, ?int $minValue = null, ?int $maxValue = null, ?int $gasPrice = null, ?int $gasLimit = null, ?int $sequenceId = null, ?bool $segwit = null, ?int $lastLedgerSequence = null, ?string $ledgerSequenceDelta = null) { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/sendcoins'; $this->params = [ 'address' => $address, - 'amount' => "$amount", + 'amount' => self::amountToString($amount), 'walletPassphrase' => $walletPassphrase, 'prv' => $prv, 'numBlocks' => in_array($this->coin, $this->UTXObased) ? $numBlocks : null, @@ -194,7 +228,7 @@ public function sendTransaction(string $address, int $amount, string $walletPass * This API call allows you to create a transaction and send to multiple addresses. * This may be useful if you schedule outgoing transactions in bulk, as you will be able to process multiple recipients and lower the aggregate amount of blockchain fees paid. * - * @param array $recipients Array of recipient objects and the amount to send to each e.g. [{address: ‘38BKDNZbPcLogvVbcx2ekJ9E6Vv94DqDqw’, amount: 1500}, …] + * @param array $recipients Array of recipient objects and the amount to send to each e.g. [{address: ‘38BKDNZbPcLogvVbcx2ekJ9E6Vv94DqDqw’, amount: 1500}, …]. Amounts are int or string, in base units. * @param string $walletPassphrase The passphrase to be used to decrypt the user key on this wallet * @param string $prv The private key in string form if the walletPassphrase is not available * @param int $numBlocks Estimates the approximate fee per kilobyte necessary for a transaction confirmation within 'numBlocks' blocks. @@ -219,7 +253,7 @@ public function sendTransactionToMany(array $recipients, string $walletPassphras { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/sendmany'; $this->params = [ - 'recipients' => $recipients, + 'recipients' => self::recipientAmountsToStrings($recipients), 'walletPassphrase' => $walletPassphrase, 'prv' => $prv, 'numBlocks' => in_array($this->coin, $this->UTXObased) ? $numBlocks : null, @@ -350,7 +384,7 @@ public function sweep(string $address, string $walletPassphrase, ?string $xprv = /** * Create a transaction with the specified parameters. This builds a transaction object, but does not sign or send it. * - * @param array $recipients List of recipients in array + * @param array $recipients List of recipients in array. Amounts are int or string, in base units. * @param int $numBlocks Estimates the approximate fee per kilobyte necessary for a transaction confirmation within 'numBlocks' blocks. * @param int $feeRate Fee rate in satoshis/litoshis/atoms per kilobyte. * @param int $minConfirms Minimum number of confirmations unspents going into this transaction should have. @@ -369,7 +403,7 @@ public function buildTransaction(array $recipients, ?int $numBlocks = null, ?int { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/tx/build'; $this->params = [ - 'recipients' => $recipients, + 'recipients' => self::recipientAmountsToStrings($recipients), 'numBlocks' => in_array($this->coin, $this->UTXObased) ? $numBlocks : null, 'feeRate' => in_array($this->coin, $this->UTXObased) ? $feeRate : null, 'minConfirms' => in_array($this->coin, $this->UTXObased) ? $minConfirms : null, @@ -555,13 +589,16 @@ public function verifyAddress(string $address) private function __execute(string $requestType = 'POST', bool $array = true) { + $params = self::withoutNulls($this->params); + $this->params = []; + $ch = curl_init($this->url); if ($requestType === 'POST') { curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array_filter($this->params))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); } elseif ($requestType === 'PUT') { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array_filter($this->params))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); } curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); @@ -575,11 +612,82 @@ private function __execute(string $requestType = 'POST', bool $array = true) if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); } + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->connectTimeout); $response = curl_exec($ch); - curl_close($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + if ($errno !== 0) { + $result = ['error' => $error !== '' ? $error : 'cURL error ' . $errno, 'status' => 0]; + return $array ? $result : (object) $result; + } + + return self::decodeResponse((string) $response, $status, $array); + } + + /** + * Decode a response body. A non-2xx status or an invalid JSON body becomes ['error' => …, 'status' => …], + * keeping any other fields Express sent. With $array false, the error comes back as an object. + * + * @return mixed + */ + private static function decodeResponse(string $body, int $status, bool $array) + { + $decoded = json_decode($body, $array); + $jsonError = trim($body) === '' ? null : (json_last_error() === JSON_ERROR_NONE ? null : json_last_error_msg()); + $ok = $status >= 200 && $status < 300; + + if ($ok && $jsonError === null) { + return $decoded; + } + + $result = is_array($decoded) ? $decoded : (is_object($decoded) ? get_object_vars($decoded) : []); + if (!isset($result['error'])) { + $result['error'] = $ok ? 'Invalid JSON response: ' . $jsonError : 'HTTP ' . $status; + } + $result['status'] = $status; + return $array ? $result : (object) $result; + } + + /** + * Amounts can exceed PHP_INT_MAX (10 ETH is 1e19 wei), so they are always sent as strings. + * + * @param mixed $amount + * @throws \InvalidArgumentException if the amount is not an integer + */ + private static function amountToString($amount): string + { + return BitGoSDK::fromBaseUnits($amount, 0); + } - return json_decode($response, $array); + /** + * @param array $recipients + * @return array + * @throws \InvalidArgumentException if an amount is not an integer + */ + private static function recipientAmountsToStrings(array $recipients): array + { + return array_map(static function ($recipient) { + if (is_array($recipient) && isset($recipient['amount'])) { + $recipient['amount'] = self::amountToString($recipient['amount']); + } + return $recipient; + }, $recipients); } + /** + * Drop only unset (null) params, so 0, false and '' are still sent. + * + * @param array $params + * @return array + */ + private static function withoutNulls(array $params): array + { + return array_filter($params, static function ($value): bool { + return $value !== null; + }); + } } diff --git a/src/BitGoSDK.php b/src/BitGoSDK.php index 6b9723e..ae4e851 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -22,6 +22,8 @@ class BitGoSDK { const BITGO_PRODUCTION_API_ENDPOINT = 'https://app.bitgo.com/api/v2/'; const BITGO_TESTNET_API_ENDPOINT = 'https://app.bitgo-test.com/api/v2/'; + const DEFAULT_TIMEOUT = 60; + const DEFAULT_CONNECT_TIMEOUT = 10; private $APIEndpoint = null; private $AuthAPIEndpoint = null; @@ -29,6 +31,8 @@ class BitGoSDK private $params = []; public $walletId = null; private $requestIP = null; + private int $timeout = self::DEFAULT_TIMEOUT; + private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; /** * BitGoSDK Initialization @@ -59,26 +63,116 @@ public function setRequestIP(string $ipAddress) } } + /** + * Set the maximum time a request may take, connection included + * + * @param int $seconds 0 waits forever + * @throws \InvalidArgumentException + */ + public function setTimeout(int $seconds): void + { + if ($seconds < 0) { + throw new \InvalidArgumentException('Timeout must not be negative.'); + } + $this->timeout = $seconds; + } + + /** + * Set the maximum time to wait for a connection to BitGo + * + * @param int $seconds 0 uses cURL's built-in default of 300 seconds + * @throws \InvalidArgumentException + */ + public function setConnectTimeout(int $seconds): void + { + if ($seconds < 0) { + throw new \InvalidArgumentException('Connect timeout must not be negative.'); + } + $this->connectTimeout = $seconds; + } + /** * Converts value in satoshi to BTC value * - * @param int $amount amount in satoshi that you want to convert to BTC - * @return float value converted to BTC + * @param int|string $amount amount in satoshi that you want to convert to BTC + * @return string value converted to BTC, with 8 decimal places + * @throws \InvalidArgumentException */ - public static function toBTC(int $amount) + public static function toBTC($amount) { - return sprintf('%.8f', bcdiv($amount, 100000000, 8)); + return self::fromBaseUnits($amount, 8); } /** * Converts value in BTC to satoshi value * - * @param float $amount amount in BTC that you want to convert to satoshi - * @return int value converted to satoshi + * @param float|int|string $amount amount in BTC that you want to convert to satoshi. Pass a string to avoid float rounding. + * @return int value converted to satoshi + * @throws \InvalidArgumentException + */ + public static function toSatoshi($amount) + { + if (is_float($amount)) { + $amount = sprintf('%.8F', $amount); + } + return (int) self::toBaseUnits((string) $amount, 8); + } + + /** + * Converts a decimal amount to an integer amount of base units, e.g. ETH to wei with 18 decimals. + * + * @param string $amount decimal amount, e.g. '1.5' + * @param int $decimals number of decimal places of the coin, e.g. 8 for BTC or 18 for ETH + * @return string amount in base units, e.g. '1500000000000000000' + * @throws \InvalidArgumentException if the amount is not a decimal number or has more than $decimals decimal places + */ + public static function toBaseUnits(string $amount, int $decimals): string + { + if ($decimals < 0) { + throw new \InvalidArgumentException('Decimals must not be negative.'); + } + if (!preg_match('/^(-?)(\d*)(?:\.(\d*))?$/', $amount, $matches) || $matches[2] . ($matches[3] ?? '') === '') { + throw new \InvalidArgumentException("Amount '$amount' is not a decimal number."); + } + + $fraction = rtrim($matches[3] ?? '', '0'); + if (strlen($fraction) > $decimals) { + throw new \InvalidArgumentException("Amount '$amount' has more than $decimals decimal places."); + } + + $units = ltrim($matches[2] . str_pad($fraction, $decimals, '0'), '0'); + return $units === '' ? '0' : $matches[1] . $units; + } + + /** + * Converts an integer amount of base units to a decimal amount, e.g. wei to ETH with 18 decimals. + * + * @param int|string|float $amount amount in base units, e.g. '1500000000000000000'. A float is accepted only if it is a whole number. + * @param int $decimals number of decimal places of the coin, e.g. 8 for BTC or 18 for ETH + * @return string decimal amount with $decimals decimal places, e.g. '1.500000000000000000' + * @throws \InvalidArgumentException if the amount is not an integer */ - public static function toSatoshi(float $amount) + public static function fromBaseUnits($amount, int $decimals): string { - return (int) bcmul(sprintf('%.8f', $amount), 100000000, 0); + if ($decimals < 0) { + throw new \InvalidArgumentException('Decimals must not be negative.'); + } + if (is_float($amount) && is_finite($amount) && floor($amount) === $amount && abs($amount) <= 2 ** 53) { + $amount = sprintf('%.0F', $amount); + } + if (!(is_int($amount) || is_string($amount)) || !preg_match('/^(-?)(\d+)$/', (string) $amount, $matches)) { + throw new \InvalidArgumentException('Amount must be an integer number of base units, as an int or a string of digits. Use toBaseUnits() to convert a decimal amount.'); + } + + $units = ltrim($matches[2], '0'); + if ($units === '') { + return $decimals === 0 ? '0' : '0.' . str_repeat('0', $decimals); + } + if ($decimals > 0) { + $units = str_pad($units, $decimals + 1, '0', STR_PAD_LEFT); + $units = substr($units, 0, -$decimals) . '.' . substr($units, -$decimals); + } + return $matches[1] . $units; } /** @@ -510,7 +604,7 @@ public function addKeychain(string $pub, ?string $encryptedPrv = null, string $s */ public function setVelocityPolicyRule(string $id, string $type, string $condition, string $action) { - $this->url = $this->APIEndpoint . '/wallet/' . $id . '/policy/rule'; + $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/policy/rule'; $this->params = [ 'id' => $id, 'type' => $type, @@ -531,7 +625,7 @@ public function setVelocityPolicyRule(string $id, string $type, string $conditio */ public function updateVelocityPolicyRule(string $id, string $type, string $condition, string $action) { - $this->url = $this->APIEndpoint . '/wallet/' . $id . '/policy/rule'; + $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/policy/rule'; $this->params = [ 'id' => $id, 'type' => $type, @@ -553,7 +647,7 @@ public function updateVelocityPolicyRule(string $id, string $type, string $condi */ public function removePolicyRule(string $id, string $type, string $condition, string $action) { - $this->url = $this->APIEndpoint . '/wallet/' . $id . '/policy/rule'; + $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/policy/rule'; $this->params = [ 'id' => $id, 'type' => $type, @@ -890,19 +984,22 @@ public function estimateTransactionFees(?int $numBlocks = null) private function __execute(string $requestType = 'POST') { + $params = self::withoutNulls($this->params); + $this->params = []; + $ch = curl_init($this->url); if ($requestType === 'GET') { - curl_setopt($ch, CURLOPT_URL, $this->url . '?' . http_build_query(array_filter($this->params))); + curl_setopt($ch, CURLOPT_URL, $this->url . '?' . self::buildQuery($params)); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); } elseif ($requestType === 'PUT') { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array_filter($this->params))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); } elseif ($requestType === 'DELETE') { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array_filter($this->params))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); } elseif ($requestType === 'POST') { curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array_filter($this->params))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); } curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); @@ -917,11 +1014,67 @@ private function __execute(string $requestType = 'POST') if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); } + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->connectTimeout); $response = curl_exec($ch); - curl_close($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - return json_decode($response, true); + if ($errno !== 0) { + return ['error' => $error !== '' ? $error : 'cURL error ' . $errno, 'status' => 0]; + } + + return self::decodeResponse((string) $response, $status); } + /** + * Decode a response body. A non-2xx status or an invalid JSON body becomes ['error' => …, 'status' => …], + * keeping any other fields BitGo sent. + * + * @return mixed + */ + private static function decodeResponse(string $body, int $status) + { + $decoded = json_decode($body, true); + $jsonError = trim($body) === '' ? null : (json_last_error() === JSON_ERROR_NONE ? null : json_last_error_msg()); + $ok = $status >= 200 && $status < 300; + + if ($ok && $jsonError === null) { + return $decoded; + } + + $result = is_array($decoded) ? $decoded : []; + if (!isset($result['error'])) { + $result['error'] = $ok ? 'Invalid JSON response: ' . $jsonError : 'HTTP ' . $status; + } + $result['status'] = $status; + return $result; + } + + /** + * Drop only unset (null) params, so 0, false and '' are still sent. + * + * @param array $params + * @return array + */ + private static function withoutNulls(array $params): array + { + return array_filter($params, static function ($value): bool { + return $value !== null; + }); + } + + /** + * Build a query string that sends booleans as "true"/"false" instead of http_build_query's 1/0. + * + * @param array $params + */ + private static function buildQuery(array $params): string + { + return http_build_query(array_map(static function ($value) { + return is_bool($value) ? ($value ? 'true' : 'false') : $value; + }, $params)); + } } diff --git a/tests/AccessesPrivateMembers.php b/tests/AccessesPrivateMembers.php new file mode 100644 index 0000000..dd6032c --- /dev/null +++ b/tests/AccessesPrivateMembers.php @@ -0,0 +1,52 @@ +setAccessible(true); + } + return $method->invoke(is_object($client) ? $client : null, ...$args); + } + + /** + * @param object $client + * @param mixed $value + */ + private function setPrivate($client, string $name, $value): void + { + $property = new ReflectionProperty($client, $name); + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + $property->setValue($client, $value); + } + + /** + * @param object $client + * @return mixed + */ + private function getPrivate($client, string $name) + { + $property = new ReflectionProperty($client, $name); + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + return $property->getValue($client); + } +} diff --git a/tests/AmountTest.php b/tests/AmountTest.php new file mode 100644 index 0000000..1efae1d --- /dev/null +++ b/tests/AmountTest.php @@ -0,0 +1,170 @@ + + */ + public static function baseUnitCases(): array + { + return [ + '1.5 ETH' => ['1.5', 18, '1500000000000000000'], + '10 ETH, above PHP_INT_MAX' => ['10', 18, '10000000000000000000'], + '1 satoshi' => ['0.00000001', 8, '1'], + 'trailing zeros' => ['1.50000000000', 8, '150000000'], + 'leading zeros' => ['007', 2, '700'], + 'no integer part' => ['.5', 1, '5'], + 'zero' => ['0.0', 8, '0'], + 'negative' => ['-0.1', 8, '-10000000'], + 'no decimals' => ['42', 0, '42'], + ]; + } + + /** + * @dataProvider baseUnitCases + */ + public function testToBaseUnits(string $amount, int $decimals, string $expected): void + { + self::assertSame($expected, BitGoSDK::toBaseUnits($amount, $decimals)); + } + + /** + * @return array + */ + public static function decimalCases(): array + { + return [ + '1.5 ETH' => ['1500000000000000000', 18, '1.500000000000000000'], + '10 ETH, above PHP_INT_MAX' => ['10000000000000000000', 18, '10.000000000000000000'], + '1 satoshi' => [1, 8, '0.00000001'], + 'zero' => [0, 8, '0.00000000'], + 'negative transfer value' => ['-150000000', 8, '-1.50000000'], + 'leading zeros' => ['00042', 0, '42'], + ]; + } + + /** + * @dataProvider decimalCases + * @param int|string $amount + */ + public function testFromBaseUnits($amount, int $decimals, string $expected): void + { + self::assertSame($expected, BitGoSDK::fromBaseUnits($amount, $decimals)); + } + + public function testToBaseUnitsRejectsExcessPrecision(): void + { + $this->expectException(\InvalidArgumentException::class); + BitGoSDK::toBaseUnits('0.000000001', 8); + } + + /** + * @return array + */ + public static function invalidDecimals(): array + { + return [ + 'empty' => [''], + 'dot only' => ['.'], + 'exponent' => ['1e5'], + 'comma' => ['1,5'], + 'text' => ['abc'], + ]; + } + + /** + * @dataProvider invalidDecimals + */ + public function testToBaseUnitsRejectsNonDecimals(string $amount): void + { + $this->expectException(\InvalidArgumentException::class); + BitGoSDK::toBaseUnits($amount, 8); + } + + /** + * @return array + */ + public static function invalidIntegers(): array + { + return [ + 'decimal string' => ['1.5'], + 'fractional float' => [1.5], + 'empty' => [''], + 'null' => [null], + ]; + } + + /** + * @dataProvider invalidIntegers + * @param mixed $amount + */ + public function testFromBaseUnitsRejectsNonIntegers($amount): void + { + $this->expectException(\InvalidArgumentException::class); + BitGoSDK::fromBaseUnits($amount, 8); + } + + public function testToSatoshiDoesNotDependOnLocale(): void + { + $previous = setlocale(LC_NUMERIC, '0'); + if (setlocale(LC_NUMERIC, 'de_DE.UTF-8', 'de_DE', 'German_Germany.1252', 'deu') === false) { + self::markTestSkipped('No locale with a decimal comma is installed.'); + } + + try { + self::assertSame(25000000, BitGoSDK::toSatoshi(0.25)); + } finally { + setlocale(LC_NUMERIC, (string) $previous); + } + } + + public function testToSatoshiAcceptsStringsAndFloats(): void + { + self::assertSame(25000000, BitGoSDK::toSatoshi(0.25)); + self::assertSame(30000000, BitGoSDK::toSatoshi(0.1 + 0.2)); + self::assertSame(2100000000000000, BitGoSDK::toSatoshi('21000000')); + self::assertSame(100000000, BitGoSDK::toSatoshi(1)); + } + + public function testToBtcIsExactAboveFloatPrecision(): void + { + self::assertSame('90071992.54740993', BitGoSDK::toBTC(9007199254740993)); + self::assertSame('0.00000001', BitGoSDK::toBTC(1)); + self::assertSame('1.00000000', BitGoSDK::toBTC('100000000')); + } + + public function testExpressSendsRecipientAmountsAsStrings(): void + { + self::assertSame( + [ + ['address' => 'a', 'amount' => '10000000000000000000'], + ['address' => 'b', 'amount' => '1500'], + ['address' => 'c'], + ], + $this->invoke(BitGoExpress::class, 'recipientAmountsToStrings', [ + ['address' => 'a', 'amount' => '10000000000000000000'], + ['address' => 'b', 'amount' => 1500], + ['address' => 'c'], + ]) + ); + } + + public function testExpressRejectsDecimalAmounts(): void + { + $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + + $this->expectException(\InvalidArgumentException::class); + $express->sendTransaction('address', '0.5', 'passphrase'); + } +} diff --git a/tests/ErrorHandlingTest.php b/tests/ErrorHandlingTest.php new file mode 100644 index 0000000..9d48ca8 --- /dev/null +++ b/tests/ErrorHandlingTest.php @@ -0,0 +1,97 @@ + …, 'status' => …]. + */ +final class ErrorHandlingTest extends TestCase +{ + use AccessesPrivateMembers; + + public function testSdkReturnsErrorWhenRequestFails(): void + { + $sdk = @new BitGoSDK('token', 'tbtc', true); + $this->setPrivate($sdk, 'APIEndpoint', 'http://127.0.0.1:1/api/v2/tbtc'); + + $result = $sdk->getWallet(); + + self::assertIsString($result['error']); + self::assertNotSame('', $result['error']); + self::assertSame(0, $result['status']); + } + + public function testExpressReturnsErrorObjectWhenObjectRequestFails(): void + { + $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + + $result = $express->buildTransaction([['address' => 'a', 'amount' => 1]]); + + self::assertInstanceOf(\stdClass::class, $result); + self::assertIsString($result->error); + self::assertSame(0, $result->status); + } + + public function testSuccessfulResponseIsReturnedUnchanged(): void + { + self::assertSame(['id' => 'w1'], $this->invoke(BitGoSDK::class, 'decodeResponse', '{"id":"w1"}', 200)); + self::assertNull($this->invoke(BitGoSDK::class, 'decodeResponse', '', 204)); + } + + public function testBitGoErrorKeepsItsFieldsAndGainsStatus(): void + { + self::assertSame( + ['error' => 'wallet not found', 'name' => 'WalletNotFound', 'status' => 404], + $this->invoke(BitGoSDK::class, 'decodeResponse', '{"error":"wallet not found","name":"WalletNotFound"}', 404) + ); + } + + public function testNonJsonErrorReportsStatus(): void + { + self::assertSame( + ['error' => 'HTTP 502', 'status' => 502], + $this->invoke(BitGoSDK::class, 'decodeResponse', 'Bad Gateway', 502) + ); + } + + public function testInvalidJsonOnSuccessIsAnError(): void + { + $result = $this->invoke(BitGoSDK::class, 'decodeResponse', '{"id":', 200); + + self::assertIsArray($result); + self::assertStringStartsWith('Invalid JSON response: ', $result['error']); + self::assertSame(200, $result['status']); + } + + public function testExpressObjectErrorKeepsItsFields(): void + { + $result = $this->invoke(BitGoExpress::class, 'decodeResponse', '{"error":"insufficient funds","name":"Error"}', 400, false); + + self::assertEquals((object) ['error' => 'insufficient funds', 'name' => 'Error', 'status' => 400], $result); + } + + public function testTimeoutsDefaultAndCanBeChanged(): void + { + $sdk = @new BitGoSDK('token', 'tbtc', true); + self::assertSame(BitGoSDK::DEFAULT_TIMEOUT, $this->getPrivate($sdk, 'timeout')); + self::assertSame(BitGoSDK::DEFAULT_CONNECT_TIMEOUT, $this->getPrivate($sdk, 'connectTimeout')); + + $sdk->setTimeout(5); + $sdk->setConnectTimeout(2); + + self::assertSame(5, $this->getPrivate($sdk, 'timeout')); + self::assertSame(2, $this->getPrivate($sdk, 'connectTimeout')); + } + + public function testNegativeTimeoutIsRejected(): void + { + $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + + $this->expectException(\InvalidArgumentException::class); + $express->setTimeout(-1); + } +} diff --git a/tests/PolicyRuleTest.php b/tests/PolicyRuleTest.php new file mode 100644 index 0000000..2c5adaf --- /dev/null +++ b/tests/PolicyRuleTest.php @@ -0,0 +1,44 @@ + + */ + public static function policyRuleMethods(): array + { + return [ + 'set' => ['setVelocityPolicyRule'], + 'update' => ['updateVelocityPolicyRule'], + 'remove' => ['removePolicyRule'], + ]; + } + + /** + * @dataProvider policyRuleMethods + */ + public function testUrlUsesWalletId(string $method): void + { + $sdk = @new BitGoSDK('token', 'tbtc', true); + $this->setPrivate($sdk, 'APIEndpoint', self::UNREACHABLE_ENDPOINT); + $sdk->walletId = 'wallet-id'; + + $sdk->$method('rule-id', 'velocityLimit', '{}', 'deny'); + + self::assertSame(self::UNREACHABLE_ENDPOINT . '/wallet/wallet-id/policy/rule', $this->getPrivate($sdk, 'url')); + } +} diff --git a/tests/RequestParamsTest.php b/tests/RequestParamsTest.php new file mode 100644 index 0000000..7f61ec9 --- /dev/null +++ b/tests/RequestParamsTest.php @@ -0,0 +1,84 @@ +setPrivate($sdk, 'params', ['otp' => '000000', 'duration' => 600]); + $this->setPrivate($sdk, 'url', self::UNREACHABLE_URL); + + $this->execute($sdk, 'POST'); + + self::assertSame([], $this->getPrivate($sdk, 'params')); + } + + public function testExpressClearsParamsAfterEachCall(): void + { + $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $this->setPrivate($express, 'params', ['walletPassphrase' => 'secret']); + $this->setPrivate($express, 'url', self::UNREACHABLE_URL); + + $this->execute($express, 'POST'); + + self::assertSame([], $this->getPrivate($express, 'params')); + } + + public function testSdkDropsOnlyNullParams(): void + { + $sdk = @new BitGoSDK('token', 'tbtc', true); + + self::assertSame( + ['limit' => 0, 'mine' => false, 'label' => ''], + $this->invoke($sdk, 'withoutNulls', ['limit' => 0, 'prevId' => null, 'mine' => false, 'label' => '']) + ); + } + + public function testExpressDropsOnlyNullParams(): void + { + $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + + self::assertSame( + ['amount' => 0, 'noSplitChange' => false], + $this->invoke($express, 'withoutNulls', ['amount' => 0, 'comment' => null, 'noSplitChange' => false]) + ); + } + + public function testSdkQuerySendsBooleansAsWords(): void + { + $sdk = @new BitGoSDK('token', 'tbtc', true); + + self::assertSame( + 'limit=0&mine=false&allTokens=true', + $this->invoke($sdk, 'buildQuery', ['limit' => 0, 'mine' => false, 'allTokens' => true]) + ); + } + + /** + * @param object $client + */ + private function execute($client, string $requestType): void + { + $this->invoke($client, '__execute', $requestType); + } +}