diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 18908dd..fa86f97 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,23 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Access to an undefined property neto737\\BitGoSDK\\BitGoExpress\:\:\$coin\.$#' - identifier: property.notFound - count: 37 - path: src/BitGoExpress.php - - - - message: '#^Access to an undefined property neto737\\BitGoSDK\\BitGoExpress\:\:\$hostname\.$#' - identifier: property.notFound - count: 1 - path: src/BitGoExpress.php - - - - message: '#^Access to an undefined property neto737\\BitGoSDK\\BitGoExpress\:\:\$port\.$#' - identifier: property.notFound - count: 1 - path: src/BitGoExpress.php - - message: '#^Method neto737\\BitGoSDK\\BitGoExpress\:\:__execute\(\) has no return type specified\.$#' identifier: missingType.return @@ -162,24 +144,12 @@ parameters: count: 1 path: src/BitGoExpress.php - - - message: '#^Property neto737\\BitGoSDK\\BitGoExpress\:\:\$UTXObased has no type specified\.$#' - identifier: missingType.property - count: 1 - path: src/BitGoExpress.php - - message: '#^Property neto737\\BitGoSDK\\BitGoExpress\:\:\$accessToken has no type specified\.$#' identifier: missingType.property count: 1 path: src/BitGoExpress.php - - - message: '#^Property neto737\\BitGoSDK\\BitGoExpress\:\:\$login has no type specified\.$#' - identifier: missingType.property - count: 1 - path: src/BitGoExpress.php - - message: '#^Property neto737\\BitGoSDK\\BitGoExpress\:\:\$params has no type specified\.$#' identifier: missingType.property @@ -198,24 +168,6 @@ parameters: count: 1 path: src/BitGoExpress.php - - - message: '#^Access to an undefined property neto737\\BitGoSDK\\BitGoSDK\:\:\$accessToken\.$#' - identifier: property.notFound - count: 2 - path: src/BitGoSDK.php - - - - message: '#^Access to an undefined property neto737\\BitGoSDK\\BitGoSDK\:\:\$coin\.$#' - identifier: property.notFound - count: 11 - path: src/BitGoSDK.php - - - - message: '#^Access to an undefined property neto737\\BitGoSDK\\BitGoSDK\:\:\$testNet\.$#' - identifier: property.notFound - count: 2 - path: src/BitGoSDK.php - - message: '#^Method neto737\\BitGoSDK\\BitGoSDK\:\:__execute\(\) has no return type specified\.$#' identifier: missingType.return diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 6461826..2b8fbf4 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -23,32 +23,79 @@ class BitGoExpress const DEFAULT_TIMEOUT = 310; const DEFAULT_CONNECT_TIMEOUT = 10; + // Coin families for coin-specific params. Each lists mainnet and testnet codes, so testnet wallets get the same params. + private const UTXO_COINS = ['btc', 'tbtc', 'bch', 'tbch', 'btg', 'tbtg', 'dash', 'tdash', 'doge', 'tdoge', 'ltc', 'tltc', 'zec', 'tzec']; + private const BTC_COINS = ['btc', 'tbtc']; + private const SEGWIT_COINS = ['btc', 'tbtc', 'btg', 'tbtg', 'ltc', 'tltc']; + private const ETH_COINS = ['eth', 'teth', 'gteth', 'hteth']; + private const XRP_COINS = ['xrp', 'txrp']; + private $APIEndpoint = null; private $AuthAPIEndpoint = null; private $url = null; private $params = []; - private $UTXObased = ['btc', 'bch', 'btg', 'dash', 'ltc', 'stx', 'zec', 'tbtc', 'tbch', 'tdash', 'tltc', 'tstx', 'tzec']; - private $login = false; public $accessToken = null; public $walletId = null; + public string $hostname; + public int $port; + public string $coin; private int $timeout = self::DEFAULT_TIMEOUT; private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; + private ?string $caFile = null; + private bool $verifyTls = true; + private bool $forceIPv4 = false; /** * BitGoExpress Initialization * - * @param string $hostname Set the hostname of your BitGo Express instance + * @param string $hostname Set the hostname of your BitGo Express instance. Prefix it with https:// if Express serves TLS. * @param int $port Set the port of your BitGo Express instance * @param string $coin Select the coin what you want to use with the BitGOSDK (use CurrencyCode class to select) - * @throws \Exception + * @throws \InvalidArgumentException if the hostname has a scheme other than http:// or https:// */ public function __construct(string $hostname, int $port, string $coin = CurrencyCode::BITCOIN) { + $scheme = 'http'; + if (preg_match('#^([a-z][a-z0-9+.-]*)://(.*)$#i', $hostname, $matches) === 1) { + $scheme = strtolower($matches[1]); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new \InvalidArgumentException('BitGo Express must be reached over http:// or https://.'); + } + $hostname = rtrim($matches[2], '/'); + } + $this->hostname = $hostname; $this->port = $port; $this->coin = $coin; - $this->AuthAPIEndpoint = 'http://' . $this->hostname . ':' . $this->port . '/api/v2'; - $this->APIEndpoint = 'http://' . $this->hostname . ':' . $this->port . '/api/v2/' . $this->coin; + $this->AuthAPIEndpoint = $scheme . '://' . $this->hostname . ':' . $this->port . '/api/v2'; + $this->APIEndpoint = $scheme . '://' . $this->hostname . ':' . $this->port . '/api/v2/' . $this->coin; + } + + /** + * Trust the CA certificates in a PEM file when connecting to an https:// Express. + * For a self-signed Express certificate, pass the certificate file itself. + * + * @param string $path Path to a PEM file + * @throws \InvalidArgumentException if the file cannot be read + */ + public function setCaFile(string $path): void + { + if (!is_file($path) || !is_readable($path)) { + throw new \InvalidArgumentException('CA file not found or not readable: ' . $path); + } + $this->caFile = $path; + } + + /** + * Turn TLS certificate and hostname verification on or off for an https:// Express. It is on by default. + * With it off, anyone who can intercept the connection can read passphrases and keys. Prefer setCaFile(), + * and only turn verification off for local testing. + * + * @param bool $verify + */ + public function setVerifyTls(bool $verify): void + { + $this->verifyTls = $verify; } /** @@ -79,6 +126,16 @@ public function setConnectTimeout(int $seconds): void $this->connectTimeout = $seconds; } + /** + * Resolve hostnames to IPv4 addresses only. Off by default, so cURL uses IPv4 or IPv6 as the system allows. + * + * @param bool $force + */ + public function setForceIPv4(bool $force): void + { + $this->forceIPv4 = $force; + } + /** * Get a token for first-party access to the BitGo API. First-party access is only intended for users accessing their own BitGo accounts. * @@ -86,7 +143,7 @@ public function setConnectTimeout(int $seconds): void * @param string $password The user’s password * @param string $otp The 2nd-factor-authentication token * @param bool $extensible True if the session is supposed to be extensible beyond a one-hour duration. - * @return array + * @return array On success, the returned access_token is also stored in $accessToken for later calls. */ public function login(string $email, string $password, string $otp, ?bool $extensible = null) { @@ -97,8 +154,9 @@ public function login(string $email, string $password, string $otp, ?bool $exten 'otp' => $otp, 'extensible' => $extensible ]; - $this->login = true; - return $this->__execute(); + $result = $this->__execute('POST', true, false); + $this->rememberAccessToken($result); + return $result; } /** @@ -137,7 +195,7 @@ public function generateWallet(string $label, string $passphrase, ?string $userK 'backupXpubProvider' => $backupXpubProvider, 'enterprise' => $enterprise, 'disableTransactionNotifications' => $disableTransactionNotifications, - 'gasPrice' => $this->coin === CurrencyCode::ETHEREUM ? $gasPrice : 0, + 'gasPrice' => $this->coinIn(self::ETH_COINS) ? $gasPrice : null, 'passcodeEncryptionCode' => $passcodeEncryptionCode ]; return $this->__execute(); @@ -204,22 +262,22 @@ public function sendTransaction(string $address, $amount, string $walletPassphra 'amount' => self::amountToString($amount), 'walletPassphrase' => $walletPassphrase, 'prv' => $prv, - 'numBlocks' => in_array($this->coin, $this->UTXObased) ? $numBlocks : null, - 'feeRate' => in_array($this->coin, $this->UTXObased) ? $feeRate : null, + 'numBlocks' => $this->coinIn(self::UTXO_COINS) ? $numBlocks : null, + 'feeRate' => $this->coinIn(self::UTXO_COINS) ? $feeRate : null, 'comment' => $comment, - 'unspents' => in_array($this->coin, $this->UTXObased) ? $unspents : null, - 'minConfirms' => in_array($this->coin, $this->UTXObased) ? $minConfirms : null, - 'enforceMinConfirmsForChange' => in_array($this->coin, $this->UTXObased) ? $enforceMinConfirmsForChange : null, - 'targetWalletUnspents' => in_array($this->coin, $this->UTXObased) ? $targetWalletUnspents : null, - 'noSplitChange' => in_array($this->coin, $this->UTXObased) ? $noSplitChange : null, - 'minValue' => in_array($this->coin, $this->UTXObased) ? $minValue : null, - 'maxValue' => in_array($this->coin, $this->UTXObased) ? $maxValue : null, - 'gasPrice' => $this->coin === CurrencyCode::ETHEREUM ? $gasPrice : null, - 'gasLimit' => $this->coin === CurrencyCode::ETHEREUM ? $gasLimit : null, - 'sequenceId' => $this->coin === CurrencyCode::ETHEREUM ? $sequenceId : null, - 'segwit' => in_array($this->coin, [CurrencyCode::BITCOIN, CurrencyCode::LITECOIN, CurrencyCode::BITCOIN_GOLD]) ? $segwit : null, - 'lastLedgerSequence' => $this->coin === CurrencyCode::RIPPLE ? $lastLedgerSequence : null, - 'ledgerSequenceDelta' => $this->coin === CurrencyCode::RIPPLE ? $ledgerSequenceDelta : null + 'unspents' => $this->coinIn(self::UTXO_COINS) ? $unspents : null, + 'minConfirms' => $this->coinIn(self::UTXO_COINS) ? $minConfirms : null, + 'enforceMinConfirmsForChange' => $this->coinIn(self::UTXO_COINS) ? $enforceMinConfirmsForChange : null, + 'targetWalletUnspents' => $this->coinIn(self::UTXO_COINS) ? $targetWalletUnspents : null, + 'noSplitChange' => $this->coinIn(self::UTXO_COINS) ? $noSplitChange : null, + 'minValue' => $this->coinIn(self::UTXO_COINS) ? $minValue : null, + 'maxValue' => $this->coinIn(self::UTXO_COINS) ? $maxValue : null, + 'gasPrice' => $this->coinIn(self::ETH_COINS) ? $gasPrice : null, + 'gasLimit' => $this->coinIn(self::ETH_COINS) ? $gasLimit : null, + 'sequenceId' => $this->coinIn(self::ETH_COINS) ? $sequenceId : null, + 'segwit' => $this->coinIn(self::SEGWIT_COINS) ? $segwit : null, + 'lastLedgerSequence' => $this->coinIn(self::XRP_COINS) ? $lastLedgerSequence : null, + 'ledgerSequenceDelta' => $this->coinIn(self::XRP_COINS) ? $ledgerSequenceDelta : null ]; return $this->__execute(); } @@ -256,22 +314,22 @@ public function sendTransactionToMany(array $recipients, string $walletPassphras 'recipients' => self::recipientAmountsToStrings($recipients), 'walletPassphrase' => $walletPassphrase, 'prv' => $prv, - 'numBlocks' => in_array($this->coin, $this->UTXObased) ? $numBlocks : null, - 'feeRate' => in_array($this->coin, $this->UTXObased) ? $feeRate : null, + 'numBlocks' => $this->coinIn(self::UTXO_COINS) ? $numBlocks : null, + 'feeRate' => $this->coinIn(self::UTXO_COINS) ? $feeRate : null, 'comment' => $comment, - 'unspents' => in_array($this->coin, $this->UTXObased) ? $unspents : null, - 'minConfirms' => in_array($this->coin, $this->UTXObased) ? $minConfirms : null, - 'enforceMinConfirmsForChange' => in_array($this->coin, $this->UTXObased) ? $enforceMinConfirmsForChange : null, - 'targetWalletUnspents' => in_array($this->coin, $this->UTXObased) ? $targetWalletUnspents : null, - 'noSplitChange' => in_array($this->coin, $this->UTXObased) ? $noSplitChange : null, - 'minValue' => in_array($this->coin, $this->UTXObased) ? $minValue : null, - 'maxValue' => in_array($this->coin, $this->UTXObased) ? $maxValue : null, - 'gasPrice' => $this->coin === CurrencyCode::ETHEREUM ? $gasPrice : null, - 'gasLimit' => $this->coin === CurrencyCode::ETHEREUM ? $gasLimit : null, - 'sequenceId' => $this->coin === CurrencyCode::ETHEREUM ? $sequenceId : null, - 'segwit' => in_array($this->coin, [CurrencyCode::BITCOIN, CurrencyCode::LITECOIN, CurrencyCode::BITCOIN_GOLD]) ? $segwit : null, - 'lastLedgerSequence' => $this->coin === CurrencyCode::RIPPLE ? $lastLedgerSequence : null, - 'ledgerSequenceDelta' => $this->coin === CurrencyCode::RIPPLE ? $ledgerSequenceDelta : null + 'unspents' => $this->coinIn(self::UTXO_COINS) ? $unspents : null, + 'minConfirms' => $this->coinIn(self::UTXO_COINS) ? $minConfirms : null, + 'enforceMinConfirmsForChange' => $this->coinIn(self::UTXO_COINS) ? $enforceMinConfirmsForChange : null, + 'targetWalletUnspents' => $this->coinIn(self::UTXO_COINS) ? $targetWalletUnspents : null, + 'noSplitChange' => $this->coinIn(self::UTXO_COINS) ? $noSplitChange : null, + 'minValue' => $this->coinIn(self::UTXO_COINS) ? $minValue : null, + 'maxValue' => $this->coinIn(self::UTXO_COINS) ? $maxValue : null, + 'gasPrice' => $this->coinIn(self::ETH_COINS) ? $gasPrice : null, + 'gasLimit' => $this->coinIn(self::ETH_COINS) ? $gasLimit : null, + 'sequenceId' => $this->coinIn(self::ETH_COINS) ? $sequenceId : null, + 'segwit' => $this->coinIn(self::SEGWIT_COINS) ? $segwit : null, + 'lastLedgerSequence' => $this->coinIn(self::XRP_COINS) ? $lastLedgerSequence : null, + 'ledgerSequenceDelta' => $this->coinIn(self::XRP_COINS) ? $ledgerSequenceDelta : null ]; return $this->__execute(); } @@ -303,7 +361,7 @@ public function consolidateWalletUnspents(string $walletPassphrase, ?int $numUns 'maxValue' => $maxValue, 'minHeight' => $minHeight, 'feeRate' => $feeRate, - 'feeTxConfirmTarget' => $this->coin === CurrencyCode::BITCOIN ? $feeTxConfirmTarget : null, + 'feeTxConfirmTarget' => $this->coinIn(self::BTC_COINS) ? $feeTxConfirmTarget : null, 'maxFeePercentage' => !isset($minValue) ? $maxFeePercentage : null, 'minConfirms' => $minConfirms, 'enforceMinConfirmsForChange' => $enforceMinConfirmsForChange @@ -343,7 +401,7 @@ public function funoutWalletUnspents(string $walletPassphrase, ?string $xprv = n 'minConfirms' => $minConfirms, 'enforceMinConfirmsForChange' => $enforceMinConfirmsForChange, 'feeRate' => $feeRate, - 'feeTxConfirmTarget' => $this->coin === CurrencyCode::BITCOIN ? $feeTxConfirmTarget : null + 'feeTxConfirmTarget' => $this->coinIn(self::BTC_COINS) ? $feeTxConfirmTarget : null ]; return $this->__execute(); } @@ -371,12 +429,12 @@ public function sweep(string $address, string $walletPassphrase, ?string $xprv = 'walletPassphrase' => $walletPassphrase, 'xprv' => $xprv, 'otp' => $otp, - 'feeRate' => in_array($this->coin, $this->UTXObased) ? $feeRate : null, - 'feeTxConfirmTarget' => in_array($this->coin, $this->UTXObased) ? $feeTxConfirmTarget : null, - 'gasPrice' => $this->coin === CurrencyCode::ETHEREUM ? $gasPrice : null, + 'feeRate' => $this->coinIn(self::UTXO_COINS) ? $feeRate : null, + 'feeTxConfirmTarget' => $this->coinIn(self::UTXO_COINS) ? $feeTxConfirmTarget : null, + 'gasPrice' => $this->coinIn(self::ETH_COINS) ? $gasPrice : null, 'sequenceId' => $sequenceId, - 'lastLedgerSequence' => $this->coin === CurrencyCode::RIPPLE ? $lastLedgerSequence : null, - 'ledgerSequenceDelta' => $this->coin === CurrencyCode::RIPPLE ? $ledgerSequenceDelta : null + 'lastLedgerSequence' => $this->coinIn(self::XRP_COINS) ? $lastLedgerSequence : null, + 'ledgerSequenceDelta' => $this->coinIn(self::XRP_COINS) ? $ledgerSequenceDelta : null ]; return $this->__execute(); } @@ -404,18 +462,18 @@ public function buildTransaction(array $recipients, ?int $numBlocks = null, ?int $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/tx/build'; $this->params = [ '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, - 'enforceMinConfirmsForChange' => in_array($this->coin, $this->UTXObased) ? $enforceMinConfirmsForChange : null, - 'unspents' => in_array($this->coin, $this->UTXObased) ? $unspents : null, - 'targetWalletUnspents' => in_array($this->coin, $this->UTXObased) ? $targetWalletUnspents : null, - 'noSplitChange' => in_array($this->coin, $this->UTXObased) ? $noSplitChange : null, - 'minValue' => in_array($this->coin, $this->UTXObased) ? $minValue : null, - 'maxValue' => in_array($this->coin, $this->UTXObased) ? $maxValue : null, - 'gasPrice' => $this->coin === CurrencyCode::ETHEREUM ? $gasPrice : null, - 'lastLedgerSequence' => $this->coin === CurrencyCode::RIPPLE ? $lastLedgerSequence : null, - 'ledgerSequenceDelta' => $this->coin === CurrencyCode::RIPPLE ? $ledgerSequenceDelta : null + 'numBlocks' => $this->coinIn(self::UTXO_COINS) ? $numBlocks : null, + 'feeRate' => $this->coinIn(self::UTXO_COINS) ? $feeRate : null, + 'minConfirms' => $this->coinIn(self::UTXO_COINS) ? $minConfirms : null, + 'enforceMinConfirmsForChange' => $this->coinIn(self::UTXO_COINS) ? $enforceMinConfirmsForChange : null, + 'unspents' => $this->coinIn(self::UTXO_COINS) ? $unspents : null, + 'targetWalletUnspents' => $this->coinIn(self::UTXO_COINS) ? $targetWalletUnspents : null, + 'noSplitChange' => $this->coinIn(self::UTXO_COINS) ? $noSplitChange : null, + 'minValue' => $this->coinIn(self::UTXO_COINS) ? $minValue : null, + 'maxValue' => $this->coinIn(self::UTXO_COINS) ? $maxValue : null, + 'gasPrice' => $this->coinIn(self::ETH_COINS) ? $gasPrice : null, + 'lastLedgerSequence' => $this->coinIn(self::XRP_COINS) ? $lastLedgerSequence : null, + 'ledgerSequenceDelta' => $this->coinIn(self::XRP_COINS) ? $ledgerSequenceDelta : null ]; return $this->__execute('POST', false); } @@ -587,33 +645,13 @@ public function verifyAddress(string $address) return $this->__execute(); } - private function __execute(string $requestType = 'POST', bool $array = true) + private function __execute(string $requestType = 'POST', bool $array = true, bool $authenticate = 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($params)); - } elseif ($requestType === 'PUT') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); - } - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - if (isset($this->accessToken) && !$this->login) { - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $this->accessToken - ]); - } - curl_setopt($ch, CURLOPT_CAINFO, dirname(__FILE__) . '/cacert.pem'); - 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); + $ch = curl_init(); + curl_setopt_array($ch, $this->curlOptions($requestType, $params, $authenticate)); $response = curl_exec($ch); $errno = curl_errno($ch); @@ -628,6 +666,62 @@ private function __execute(string $requestType = 'POST', bool $array = true) return self::decodeResponse((string) $response, $status, $array); } + /** + * The cURL options for a request to $this->url. + * + * @param array $params + * @param bool $authenticate False to leave out the Authorization header, as login() does + * @return array + */ + private function curlOptions(string $requestType, array $params, bool $authenticate = true): array + { + $options = [CURLOPT_URL => $this->url]; + if ($requestType === 'POST') { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = json_encode($params); + } elseif ($requestType === 'PUT') { + $options[CURLOPT_CUSTOMREQUEST] = 'PUT'; + $options[CURLOPT_POSTFIELDS] = json_encode($params); + } + $options[CURLOPT_SSL_VERIFYPEER] = $this->verifyTls; + $options[CURLOPT_SSL_VERIFYHOST] = $this->verifyTls ? 2 : 0; + $options[CURLOPT_RETURNTRANSFER] = true; + $headers = ['Content-Type: application/json']; + if ($authenticate && isset($this->accessToken)) { + $headers[] = 'Authorization: Bearer ' . $this->accessToken; + } + $options[CURLOPT_HTTPHEADER] = $headers; + $options[CURLOPT_CAINFO] = $this->caFile ?? dirname(__FILE__) . '/cacert.pem'; + if ($this->forceIPv4) { + $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; + } + $options[CURLOPT_TIMEOUT] = $this->timeout; + $options[CURLOPT_CONNECTTIMEOUT] = $this->connectTimeout; + return $options; + } + + /** + * Keep the token from a successful login, so later calls are authenticated with it. + * + * @param mixed $result + */ + private function rememberAccessToken($result): void + { + if (is_array($result) && !isset($result['error']) && isset($result['access_token']) && is_string($result['access_token'])) { + $this->accessToken = $result['access_token']; + } + } + + /** + * Whether this client's coin is one of $coins. + * + * @param list $coins + */ + private function coinIn(array $coins): bool + { + return in_array($this->coin, $coins, true); + } + /** * 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. diff --git a/src/BitGoSDK.php b/src/BitGoSDK.php index ae4e851..f1eb365 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -25,14 +25,23 @@ class BitGoSDK const DEFAULT_TIMEOUT = 60; const DEFAULT_CONNECT_TIMEOUT = 10; + // Coin families for coin-specific params. Each lists mainnet and testnet codes, so testnet wallets get the same params. + private const ETH_COINS = ['eth', 'teth', 'gteth', 'hteth']; + private const XRP_COINS = ['xrp', 'txrp']; + private const XLM_COINS = ['xlm', 'txlm']; + private $APIEndpoint = null; private $AuthAPIEndpoint = null; private $url = null; private $params = []; public $walletId = null; private $requestIP = null; + public string $accessToken; + public string $coin; + public bool $testNet; private int $timeout = self::DEFAULT_TIMEOUT; private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; + private bool $forceIPv4 = false; /** * BitGoSDK Initialization @@ -91,6 +100,16 @@ public function setConnectTimeout(int $seconds): void $this->connectTimeout = $seconds; } + /** + * Resolve hostnames to IPv4 addresses only. Off by default, so cURL uses IPv4 or IPv6 as the system allows. + * + * @param bool $force + */ + public function setForceIPv4(bool $force): void + { + $this->forceIPv4 = $force; + } + /** * Converts value in satoshi to BTC value * @@ -250,7 +269,7 @@ public function listWallets(int $limit = 25, ?string $prevId = null, bool $allTo $this->params = [ 'limit' => $limit, 'prevId' => $prevId, - 'allTokens' => $this->coin === CurrencyCode::ETHEREUM || $this->coin === CurrencyCode::ETHEREUM_TESTNET ? $allTokens : null, + 'allTokens' => $this->coinIn(self::ETH_COINS) ? $allTokens : null, ]; return $this->__execute('GET'); } @@ -265,7 +284,7 @@ public function getWallet(bool $allTokens = false) { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId; $this->params = [ - 'allTokens' => $this->coin === CurrencyCode::ETHEREUM || $this->coin === CurrencyCode::ETHEREUM_TESTNET ? $allTokens : null, + 'allTokens' => $this->coinIn(self::ETH_COINS) ? $allTokens : null, ]; return $this->__execute('GET'); } @@ -294,7 +313,7 @@ public function listWalletTransfers(?string $prevId = null, bool $allTokens = fa $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/transfer'; $this->params = [ 'prevId' => $prevId, - 'allTokens' => $this->coin === CurrencyCode::ETHEREUM || $this->coin === CurrencyCode::ETHEREUM_TESTNET ? $allTokens : null, + 'allTokens' => $this->coinIn(self::ETH_COINS) ? $allTokens : null, ]; return $this->__execute('GET'); } @@ -339,8 +358,8 @@ public function createWalletAddress(int $chain = AddressType::P2SH_DEPOSIT, ?str $this->params = [ 'chain' => $chain, 'label' => $label, - 'lowPriority' => $this->coin === CurrencyCode::ETHEREUM ? $lowPriority : null, - 'gasPrice' => $this->coin === CurrencyCode::ETHEREUM ? $gasPrice : null, + 'lowPriority' => $this->coinIn(self::ETH_COINS) ? $lowPriority : null, + 'gasPrice' => $this->coinIn(self::ETH_COINS) ? $gasPrice : null, ]; return $this->__execute(); } @@ -355,8 +374,8 @@ public function getWalletAddress(string $addressOrId, ?string $dt = null, ?strin { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/address/' . $addressOrId; $this->params = [ - 'dt' => $this->coin === CurrencyCode::RIPPLE ? $dt : null, - 'memoId' => $this->coin === CurrencyCode::STELLAR ? $memoId : null + 'dt' => $this->coinIn(self::XRP_COINS) ? $dt : null, + 'memoId' => $this->coinIn(self::XLM_COINS) ? $memoId : null ]; return $this->__execute('GET'); } @@ -372,8 +391,8 @@ public function updateWalletAddress(string $addressOrId, ?string $dt = null, ?st { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/address/' . $addressOrId; $this->params = [ - 'dt' => $this->coin === CurrencyCode::RIPPLE ? $dt : null, - 'memoId' => $this->coin === CurrencyCode::STELLAR ? $memoId : null, + 'dt' => $this->coinIn(self::XRP_COINS) ? $dt : null, + 'memoId' => $this->coinIn(self::XLM_COINS) ? $memoId : null, 'label' => $label ]; return $this->__execute('PUT'); @@ -413,7 +432,7 @@ public function getWalletTransactions(?string $prevId = null, bool $allTokens = $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/tx'; $this->params = [ 'prevId' => $prevId, - 'allTokens' => $this->coin === CurrencyCode::ETHEREUM || $this->coin === CurrencyCode::ETHEREUM_TESTNET ? $allTokens : null, + 'allTokens' => $this->coinIn(self::ETH_COINS) ? $allTokens : null, ]; return $this->__execute('GET'); } @@ -550,8 +569,8 @@ public function createBitGoKeychain(string $source = 'bitgo', ?string $enterpris $this->url = $this->APIEndpoint . '/key'; $this->params = [ 'source' => $source, - 'enterprise' => $this->coin === CurrencyCode::ETHEREUM ? $enterprise : null, - 'newFeeAddress' => $this->coin === CurrencyCode::ETHEREUM ? $newFeeAddress : null + 'enterprise' => $this->coinIn(self::ETH_COINS) ? $enterprise : null, + 'newFeeAddress' => $this->coinIn(self::ETH_COINS) ? $newFeeAddress : null ]; return $this->__execute(); } @@ -749,8 +768,8 @@ public function listPendingApprovals(?string $walletID = null, ?string $enterpri $this->url = $this->APIEndpoint . '/pendingapprovals'; $this->params = [ 'walletID' => $walletID, - 'enterprise' => $this->coin === CurrencyCode::ETHEREUM ? $enterprise : null, - 'allTokens' => $this->coin === CurrencyCode::ETHEREUM || $this->coin === CurrencyCode::ETHEREUM_TESTNET ? $allTokens : null + 'enterprise' => $this->coinIn(self::ETH_COINS) ? $enterprise : null, + 'allTokens' => $this->coinIn(self::ETH_COINS) ? $allTokens : null ]; return $this->__execute('GET'); } @@ -781,7 +800,7 @@ public function listWalletWebhooks(bool $allTokens = false) { $this->url = $this->APIEndpoint . '/wallet/' . $this->walletId . '/webhooks'; $this->params = [ - 'allTokens' => $this->coin === CurrencyCode::ETHEREUM || $this->coin === CurrencyCode::ETHEREUM_TESTNET ? $allTokens : null + 'allTokens' => $this->coinIn(self::ETH_COINS) ? $allTokens : null ]; return $this->__execute('GET'); } @@ -987,35 +1006,8 @@ 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 . '?' . 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($params)); - } elseif ($requestType === 'DELETE') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); - } elseif ($requestType === 'POST') { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); - } - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $this->accessToken - ]); - curl_setopt($ch, CURLOPT_CAINFO, dirname(__FILE__) . '/cacert.pem'); - if (isset($this->requestIP)) { - curl_setopt($ch, CURLOPT_INTERFACE, $this->requestIP); - } - 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); + $ch = curl_init(); + curl_setopt_array($ch, $this->curlOptions($requestType, $params)); $response = curl_exec($ch); $errno = curl_errno($ch); @@ -1029,6 +1021,58 @@ private function __execute(string $requestType = 'POST') return self::decodeResponse((string) $response, $status); } + /** + * The cURL options for a request to $this->url. + * + * @param array $params + * @return array + */ + private function curlOptions(string $requestType, array $params): array + { + $options = [CURLOPT_URL => $this->url]; + if ($requestType === 'GET') { + $options[CURLOPT_URL] = $this->url . '?' . self::buildQuery($params); + $options[CURLOPT_CUSTOMREQUEST] = 'GET'; + } elseif ($requestType === 'PUT') { + $options[CURLOPT_CUSTOMREQUEST] = 'PUT'; + $options[CURLOPT_POSTFIELDS] = json_encode($params); + } elseif ($requestType === 'DELETE') { + $options[CURLOPT_CUSTOMREQUEST] = 'DELETE'; + $options[CURLOPT_POSTFIELDS] = json_encode($params); + } elseif ($requestType === 'POST') { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = json_encode($params); + } + $options[CURLOPT_SSL_VERIFYPEER] = true; + $options[CURLOPT_RETURNTRANSFER] = true; + $options[CURLOPT_HTTPHEADER] = [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $this->accessToken + ]; + $options[CURLOPT_CAINFO] = dirname(__FILE__) . '/cacert.pem'; + if (isset($this->requestIP)) { + $options[CURLOPT_INTERFACE] = $this->requestIP; + // A bound local address can only reach hosts of the same IP version. + $options[CURLOPT_IPRESOLVE] = filter_var($this->requestIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false ? CURL_IPRESOLVE_V4 : CURL_IPRESOLVE_V6; + } + if ($this->forceIPv4) { + $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; + } + $options[CURLOPT_TIMEOUT] = $this->timeout; + $options[CURLOPT_CONNECTTIMEOUT] = $this->connectTimeout; + return $options; + } + + /** + * Whether this client's coin is one of $coins. + * + * @param list $coins + */ + private function coinIn(array $coins): bool + { + return in_array($this->coin, $coins, true); + } + /** * Decode a response body. A non-2xx status or an invalid JSON body becomes ['error' => …, 'status' => …], * keeping any other fields BitGo sent. diff --git a/tests/AmountTest.php b/tests/AmountTest.php index 1efae1d..fb71836 100644 --- a/tests/AmountTest.php +++ b/tests/AmountTest.php @@ -162,7 +162,7 @@ public function testExpressSendsRecipientAmountsAsStrings(): void public function testExpressRejectsDecimalAmounts(): void { - $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); $this->expectException(\InvalidArgumentException::class); $express->sendTransaction('address', '0.5', 'passphrase'); diff --git a/tests/CoinFamilyTest.php b/tests/CoinFamilyTest.php new file mode 100644 index 0000000..e286289 --- /dev/null +++ b/tests/CoinFamilyTest.php @@ -0,0 +1,87 @@ + + */ + public function expressProvider(): array + { + return [ + 'btc is UTXO' => ['UTXO_COINS', 'btc', true], + 'doge is UTXO' => ['UTXO_COINS', 'doge', true], + 'tdoge is UTXO' => ['UTXO_COINS', 'tdoge', true], + 'tbtg is UTXO' => ['UTXO_COINS', 'tbtg', true], + 'stx is not UTXO' => ['UTXO_COINS', 'stx', false], + 'tstx is not UTXO' => ['UTXO_COINS', 'tstx', false], + 'eth is not UTXO' => ['UTXO_COINS', 'eth', false], + 'tbtc is BTC' => ['BTC_COINS', 'tbtc', true], + 'ltc is not BTC' => ['BTC_COINS', 'ltc', false], + 'tltc is SegWit' => ['SEGWIT_COINS', 'tltc', true], + 'tbtg is SegWit' => ['SEGWIT_COINS', 'tbtg', true], + 'bch is not SegWit' => ['SEGWIT_COINS', 'bch', false], + 'teth is ETH' => ['ETH_COINS', 'teth', true], + 'hteth is ETH' => ['ETH_COINS', 'hteth', true], + 'txrp is XRP' => ['XRP_COINS', 'txrp', true], + ]; + } + + /** + * @dataProvider expressProvider + */ + public function testExpressCoinFamilies(string $family, string $coin, bool $expected): void + { + $express = new BitGoExpress('127.0.0.1', 1, $coin); + + self::assertSame($expected, $this->invoke($express, 'coinIn', self::family(BitGoExpress::class, $family))); + } + + /** + * @return array + */ + public function sdkProvider(): array + { + return [ + 'eth is ETH' => ['ETH_COINS', 'eth', true], + 'teth is ETH' => ['ETH_COINS', 'teth', true], + 'gteth is ETH' => ['ETH_COINS', 'gteth', true], + 'hteth is ETH' => ['ETH_COINS', 'hteth', true], + 'tbtc is not ETH' => ['ETH_COINS', 'tbtc', false], + 'txrp is XRP' => ['XRP_COINS', 'txrp', true], + 'txlm is XLM' => ['XLM_COINS', 'txlm', true], + 'xrp is not XLM' => ['XLM_COINS', 'xrp', false], + ]; + } + + /** + * @dataProvider sdkProvider + */ + public function testSdkCoinFamilies(string $family, string $coin, bool $expected): void + { + $sdk = new BitGoSDK('token', $coin, true); + + self::assertSame($expected, $this->invoke($sdk, 'coinIn', self::family(BitGoSDK::class, $family))); + } + + /** + * @param class-string $class + * @return mixed + */ + private static function family(string $class, string $name) + { + return (new ReflectionClassConstant($class, $name))->getValue(); + } +} diff --git a/tests/ErrorHandlingTest.php b/tests/ErrorHandlingTest.php index 9d48ca8..15153aa 100644 --- a/tests/ErrorHandlingTest.php +++ b/tests/ErrorHandlingTest.php @@ -15,7 +15,7 @@ final class ErrorHandlingTest extends TestCase public function testSdkReturnsErrorWhenRequestFails(): void { - $sdk = @new BitGoSDK('token', 'tbtc', true); + $sdk = new BitGoSDK('token', 'tbtc', true); $this->setPrivate($sdk, 'APIEndpoint', 'http://127.0.0.1:1/api/v2/tbtc'); $result = $sdk->getWallet(); @@ -27,7 +27,7 @@ public function testSdkReturnsErrorWhenRequestFails(): void public function testExpressReturnsErrorObjectWhenObjectRequestFails(): void { - $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); $result = $express->buildTransaction([['address' => 'a', 'amount' => 1]]); @@ -76,7 +76,7 @@ public function testExpressObjectErrorKeepsItsFields(): void public function testTimeoutsDefaultAndCanBeChanged(): void { - $sdk = @new BitGoSDK('token', 'tbtc', true); + $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')); @@ -89,7 +89,7 @@ public function testTimeoutsDefaultAndCanBeChanged(): void public function testNegativeTimeoutIsRejected(): void { - $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); $this->expectException(\InvalidArgumentException::class); $express->setTimeout(-1); diff --git a/tests/ExpressLoginTest.php b/tests/ExpressLoginTest.php new file mode 100644 index 0000000..4e2ac5b --- /dev/null +++ b/tests/ExpressLoginTest.php @@ -0,0 +1,75 @@ +headers($express)); + } + + public function testRequestSendsToken(): void + { + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express->accessToken = 'v2xtoken'; + + self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken'], $this->headers($express)); + } + + public function testLoginRequestSendsNoToken(): void + { + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express->accessToken = 'v2xtoken'; + + self::assertSame(['Content-Type: application/json'], $this->headers($express, false)); + } + + public function testCallsAfterLoginSendToken(): void + { + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express->accessToken = 'v2xtoken'; + + $express->login('user@example.com', 'password', '000000'); + + self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken'], $this->headers($express)); + } + + public function testSuccessfulLoginStoresToken(): void + { + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + + $this->invoke($express, 'rememberAccessToken', ['access_token' => 'v2xnew', 'token_type' => 'bearer']); + + self::assertSame('v2xnew', $express->accessToken); + } + + public function testFailedLoginKeepsToken(): void + { + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express->accessToken = 'v2xtoken'; + + $this->invoke($express, 'rememberAccessToken', ['error' => 'needs a valid otp', 'status' => 401]); + + self::assertSame('v2xtoken', $express->accessToken); + } + + /** + * @return mixed + */ + private function headers(BitGoExpress $express, bool $authenticate = true) + { + return $this->invoke($express, 'curlOptions', 'POST', [], $authenticate)[CURLOPT_HTTPHEADER]; + } +} diff --git a/tests/ExpressTlsTest.php b/tests/ExpressTlsTest.php new file mode 100644 index 0000000..6e067d2 --- /dev/null +++ b/tests/ExpressTlsTest.php @@ -0,0 +1,90 @@ +getPrivate($express, 'AuthAPIEndpoint')); + self::assertSame('http://localhost:3080/api/v2/tbtc', $this->getPrivate($express, 'APIEndpoint')); + self::assertSame('localhost', $express->hostname); + } + + public function testHttpsPrefixUsesHttps(): void + { + $express = new BitGoExpress('https://express.internal/', 3080, 'tbtc'); + + self::assertSame('https://express.internal:3080/api/v2', $this->getPrivate($express, 'AuthAPIEndpoint')); + self::assertSame('https://express.internal:3080/api/v2/tbtc', $this->getPrivate($express, 'APIEndpoint')); + self::assertSame('express.internal', $express->hostname); + } + + public function testSchemeIsCaseInsensitive(): void + { + $express = new BitGoExpress('HTTPS://express.internal', 3080, 'tbtc'); + + self::assertSame('https://express.internal:3080/api/v2', $this->getPrivate($express, 'AuthAPIEndpoint')); + } + + public function testOtherSchemesAreRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + new BitGoExpress('ftp://express.internal', 3080, 'tbtc'); + } + + public function testCertificatesAreVerifiedByDefault(): void + { + $options = $this->curlOptions(new BitGoExpress('https://express.internal', 3080, 'tbtc')); + + self::assertTrue($options[CURLOPT_SSL_VERIFYPEER]); + self::assertSame(2, $options[CURLOPT_SSL_VERIFYHOST]); + self::assertStringEndsWith('cacert.pem', $options[CURLOPT_CAINFO]); + } + + public function testCustomCaFileIsUsed(): void + { + $express = new BitGoExpress('https://express.internal', 3080, 'tbtc'); + + $express->setCaFile(__FILE__); + + self::assertSame(__FILE__, $this->curlOptions($express)[CURLOPT_CAINFO]); + } + + public function testMissingCaFileIsRejected(): void + { + $express = new BitGoExpress('https://express.internal', 3080, 'tbtc'); + + $this->expectException(\InvalidArgumentException::class); + $express->setCaFile(__DIR__ . '/does-not-exist.pem'); + } + + public function testVerificationCanBeTurnedOff(): void + { + $express = new BitGoExpress('https://express.internal', 3080, 'tbtc'); + + $express->setVerifyTls(false); + $options = $this->curlOptions($express); + + self::assertFalse($options[CURLOPT_SSL_VERIFYPEER]); + self::assertSame(0, $options[CURLOPT_SSL_VERIFYHOST]); + } + + /** + * @return array + */ + private function curlOptions(BitGoExpress $express): array + { + return $this->invoke($express, 'curlOptions', 'POST', []); + } +} diff --git a/tests/IpResolveTest.php b/tests/IpResolveTest.php new file mode 100644 index 0000000..91d4aab --- /dev/null +++ b/tests/IpResolveTest.php @@ -0,0 +1,58 @@ +invoke(new BitGoSDK('token', 'tbtc'), 'curlOptions', 'GET', []); + + self::assertArrayNotHasKey(CURLOPT_IPRESOLVE, $options); + } + + public function testSdkCanForceIPv4(): void + { + $sdk = new BitGoSDK('token', 'tbtc'); + + $sdk->setForceIPv4(true); + + self::assertSame(CURL_IPRESOLVE_V4, $this->invoke($sdk, 'curlOptions', 'GET', [])[CURLOPT_IPRESOLVE]); + } + + public function testSdkResolvesTheRequestIpVersion(): void + { + $sdk = new BitGoSDK('token', 'btc'); + + $sdk->setRequestIP('192.0.2.10'); + self::assertSame(CURL_IPRESOLVE_V4, $this->invoke($sdk, 'curlOptions', 'GET', [])[CURLOPT_IPRESOLVE]); + + $sdk->setRequestIP('2001:db8::10'); + self::assertSame(CURL_IPRESOLVE_V6, $this->invoke($sdk, 'curlOptions', 'GET', [])[CURLOPT_IPRESOLVE]); + } + + public function testExpressDoesNotForceIPv4ByDefault(): void + { + $options = $this->invoke(new BitGoExpress('127.0.0.1', 1, 'tbtc'), 'curlOptions', 'POST', []); + + self::assertArrayNotHasKey(CURLOPT_IPRESOLVE, $options); + } + + public function testExpressCanForceIPv4(): void + { + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + + $express->setForceIPv4(true); + + self::assertSame(CURL_IPRESOLVE_V4, $this->invoke($express, 'curlOptions', 'POST', [])[CURLOPT_IPRESOLVE]); + } +} diff --git a/tests/NullableParametersTest.php b/tests/NullableParametersTest.php new file mode 100644 index 0000000..7e87795 --- /dev/null +++ b/tests/NullableParametersTest.php @@ -0,0 +1,68 @@ + + */ + public function clientProvider(): array + { + return [ + 'BitGoSDK' => [BitGoSDK::class], + 'BitGoExpress' => [BitGoExpress::class], + ]; + } + + /** + * @dataProvider clientProvider + * @param class-string $class + */ + public function testNullDefaultsAreExplicitlyNullable(string $class): void + { + $implicit = []; + foreach ((new ReflectionClass($class))->getMethods() as $method) { + foreach ($method->getParameters() as $parameter) { + if (!$parameter->isDefaultValueAvailable() || $parameter->getDefaultValue() !== null) { + continue; + } + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !self::declaresNull($method->getFileName(), $method->getStartLine(), $method->getEndLine(), $parameter->getName())) { + $implicit[] = $method->getName() . '($' . $parameter->getName() . ')'; + } + } + } + + self::assertSame([], $implicit); + } + + /** + * Reflection reports `string $x = null` as nullable, so read the declaration from the source. + * + * @param string|false $file + * @param int|false $startLine + * @param int|false $endLine + */ + private static function declaresNull($file, $startLine, $endLine, string $name): bool + { + self::assertIsString($file); + self::assertIsInt($startLine); + self::assertIsInt($endLine); + + $lines = file($file); + self::assertIsArray($lines); + $source = implode('', array_slice($lines, $startLine - 1, $endLine - $startLine + 1)); + + return preg_match('/\?\\\\?[A-Za-z_][A-Za-z0-9_\\\\]*\s+\$' . preg_quote($name, '/') . '\s*=\s*null\b/i', $source) === 1; + } +} diff --git a/tests/PolicyRuleTest.php b/tests/PolicyRuleTest.php index 2c5adaf..01044b4 100644 --- a/tests/PolicyRuleTest.php +++ b/tests/PolicyRuleTest.php @@ -33,7 +33,7 @@ public static function policyRuleMethods(): array */ public function testUrlUsesWalletId(string $method): void { - $sdk = @new BitGoSDK('token', 'tbtc', true); + $sdk = new BitGoSDK('token', 'tbtc', true); $this->setPrivate($sdk, 'APIEndpoint', self::UNREACHABLE_ENDPOINT); $sdk->walletId = 'wallet-id'; diff --git a/tests/PropertiesTest.php b/tests/PropertiesTest.php new file mode 100644 index 0000000..b5e272c --- /dev/null +++ b/tests/PropertiesTest.php @@ -0,0 +1,58 @@ +constructWithoutDeprecations(static function (): BitGoSDK { + return new BitGoSDK('token', 'tbtc', true); + }); + + self::assertSame('token', $sdk->accessToken); + self::assertSame('tbtc', $sdk->coin); + self::assertTrue($sdk->testNet); + } + + public function testExpressConstructsWithoutDeprecations(): void + { + $express = $this->constructWithoutDeprecations(static function (): BitGoExpress { + return new BitGoExpress('localhost', 3080, 'tbtc'); + }); + + self::assertSame('localhost', $express->hostname); + self::assertSame(3080, $express->port); + self::assertSame('tbtc', $express->coin); + } + + /** + * @template T of object + * @param callable(): T $construct + * @return T + */ + private function constructWithoutDeprecations(callable $construct) + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + return true; + }, E_DEPRECATED | E_USER_DEPRECATED); + + try { + $client = $construct(); + } finally { + restore_error_handler(); + } + + self::assertSame([], $deprecations); + return $client; + } +} diff --git a/tests/RequestParamsTest.php b/tests/RequestParamsTest.php index 7f61ec9..73ed2ba 100644 --- a/tests/RequestParamsTest.php +++ b/tests/RequestParamsTest.php @@ -12,9 +12,6 @@ * * Until requests go through a capturable transport, these tests point the client * at a closed local port and inspect the internal params state after the call. - * - * Clients are constructed with `@` to silence the dynamic-property deprecations - * tracked as B8; drop it once those properties are declared. */ final class RequestParamsTest extends TestCase { @@ -24,7 +21,7 @@ final class RequestParamsTest extends TestCase public function testSdkClearsParamsAfterEachCall(): void { - $sdk = @new BitGoSDK('token', 'tbtc', true); + $sdk = new BitGoSDK('token', 'tbtc', true); $this->setPrivate($sdk, 'params', ['otp' => '000000', 'duration' => 600]); $this->setPrivate($sdk, 'url', self::UNREACHABLE_URL); @@ -35,7 +32,7 @@ public function testSdkClearsParamsAfterEachCall(): void public function testExpressClearsParamsAfterEachCall(): void { - $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); $this->setPrivate($express, 'params', ['walletPassphrase' => 'secret']); $this->setPrivate($express, 'url', self::UNREACHABLE_URL); @@ -46,7 +43,7 @@ public function testExpressClearsParamsAfterEachCall(): void public function testSdkDropsOnlyNullParams(): void { - $sdk = @new BitGoSDK('token', 'tbtc', true); + $sdk = new BitGoSDK('token', 'tbtc', true); self::assertSame( ['limit' => 0, 'mine' => false, 'label' => ''], @@ -56,7 +53,7 @@ public function testSdkDropsOnlyNullParams(): void public function testExpressDropsOnlyNullParams(): void { - $express = @new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); self::assertSame( ['amount' => 0, 'noSplitChange' => false], @@ -66,7 +63,7 @@ public function testExpressDropsOnlyNullParams(): void public function testSdkQuerySendsBooleansAsWords(): void { - $sdk = @new BitGoSDK('token', 'tbtc', true); + $sdk = new BitGoSDK('token', 'tbtc', true); self::assertSame( 'limit=0&mine=false&allTokens=true',