From 7afcb692f8a4e78a0f83f62dc81483cf1aff2f4c Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:41:18 +1030 Subject: [PATCH 1/7] Declare every property, so PHP 8.2+ raises no dynamic-property deprecations. BitGoSDK now declares accessToken, coin and testNet, and BitGoExpress declares hostname, port and coin. They stay public, as they were when PHP created them dynamically, so existing reads keep working. The tests no longer construct clients with @. --- phpstan-baseline.neon | 36 ----------------------- src/BitGoExpress.php | 3 ++ src/BitGoSDK.php | 3 ++ tests/AmountTest.php | 2 +- tests/ErrorHandlingTest.php | 8 ++--- tests/PolicyRuleTest.php | 2 +- tests/PropertiesTest.php | 58 +++++++++++++++++++++++++++++++++++++ tests/RequestParamsTest.php | 13 ++++----- 8 files changed, 75 insertions(+), 50 deletions(-) create mode 100644 tests/PropertiesTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 18908dd..0aa552c 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 @@ -198,24 +180,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..a0255ff 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -31,6 +31,9 @@ class BitGoExpress 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; diff --git a/src/BitGoSDK.php b/src/BitGoSDK.php index ae4e851..b3b4630 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -31,6 +31,9 @@ class BitGoSDK 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; 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/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/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', From c129672672d9e256477fb64c6f002f99151b55bd Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:41:49 +1030 Subject: [PATCH 2/7] Add a regression test for implicitly nullable parameters. The PSR-12 pass in cd4c88c already converted every `string $x = null` to `?string $x = null`. This test reads each method's declaration and fails if a typed parameter that defaults to null is no longer marked nullable, since PHP 8.4 deprecates the implicit form. --- tests/NullableParametersTest.php | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/NullableParametersTest.php 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; + } +} From 6c5983f4ee3652c7098ee4dc63caae31b5ca552e Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:43:05 +1030 Subject: [PATCH 3/7] Build each request's cURL options in one method. __execute() now passes the result of curlOptions() to curl_setopt_array(), so tests can check URLs, headers and TLS settings without making a request. No behaviour change. --- src/BitGoExpress.php | 57 +++++++++++++++++++++-------------- src/BitGoSDK.php | 71 ++++++++++++++++++++++++++------------------ 2 files changed, 77 insertions(+), 51 deletions(-) diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index a0255ff..157cd52 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -595,28 +595,8 @@ 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($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)); $response = curl_exec($ch); $errno = curl_errno($ch); @@ -631,6 +611,39 @@ 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 + * @return array + */ + private function curlOptions(string $requestType, array $params): 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] = true; + $options[CURLOPT_RETURNTRANSFER] = true; + if (isset($this->accessToken) && !$this->login) { + $options[CURLOPT_HTTPHEADER] = [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $this->accessToken + ]; + } + $options[CURLOPT_CAINFO] = dirname(__FILE__) . '/cacert.pem'; + if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { + $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; + } + $options[CURLOPT_TIMEOUT] = $this->timeout; + $options[CURLOPT_CONNECTTIMEOUT] = $this->connectTimeout; + return $options; + } + /** * 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 b3b4630..ac4e6f9 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -990,35 +990,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); @@ -1032,6 +1005,46 @@ 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; + } + if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { + $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; + } + $options[CURLOPT_TIMEOUT] = $this->timeout; + $options[CURLOPT_CONNECTTIMEOUT] = $this->connectTimeout; + return $options; + } + /** * Decode a response body. A non-2xx status or an invalid JSON body becomes ['error' => …, 'status' => …], * keeping any other fields BitGo sent. From 979fa56f624793d950d4b5d7083e3982de039ca8 Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:44:13 +1030 Subject: [PATCH 4/7] Store the Express login token and stop dropping headers after login(). login() set a flag that was never cleared, so every later call went out with no Authorization or Content-Type header. The token it returned was thrown away too. login() now stores a successful response's access_token in $accessToken, and only the login request itself leaves out the Authorization header. Every request sends Content-Type: application/json. --- phpstan-baseline.neon | 6 --- src/BitGoExpress.php | 36 ++++++++++++------ tests/ExpressLoginTest.php | 75 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 tests/ExpressLoginTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0aa552c..4813ca6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -156,12 +156,6 @@ parameters: 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 diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 157cd52..7761508 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -28,7 +28,6 @@ class BitGoExpress 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; @@ -89,7 +88,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) { @@ -100,8 +99,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; } /** @@ -590,13 +590,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(); - curl_setopt_array($ch, $this->curlOptions($requestType, $params)); + curl_setopt_array($ch, $this->curlOptions($requestType, $params, $authenticate)); $response = curl_exec($ch); $errno = curl_errno($ch); @@ -615,9 +615,10 @@ private function __execute(string $requestType = 'POST', bool $array = true) * 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): array + private function curlOptions(string $requestType, array $params, bool $authenticate = true): array { $options = [CURLOPT_URL => $this->url]; if ($requestType === 'POST') { @@ -629,12 +630,11 @@ private function curlOptions(string $requestType, array $params): array } $options[CURLOPT_SSL_VERIFYPEER] = true; $options[CURLOPT_RETURNTRANSFER] = true; - if (isset($this->accessToken) && !$this->login) { - $options[CURLOPT_HTTPHEADER] = [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $this->accessToken - ]; + $headers = ['Content-Type: application/json']; + if ($authenticate && isset($this->accessToken)) { + $headers[] = 'Authorization: Bearer ' . $this->accessToken; } + $options[CURLOPT_HTTPHEADER] = $headers; $options[CURLOPT_CAINFO] = dirname(__FILE__) . '/cacert.pem'; if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; @@ -644,6 +644,18 @@ private function curlOptions(string $requestType, array $params): array 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']; + } + } + /** * 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/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]; + } +} From 704c6c6950ec7983f0a546acc1f7b9590b970640 Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:45:46 +1030 Subject: [PATCH 5/7] Let BitGoExpress connect to Express over https://. Express could only be reached over plain http://, so passphrases and private keys crossed the network unencrypted whenever Express ran on another host. Prefix the hostname with https:// to use TLS, for example new BitGoExpress('https://express.internal', 3080). A bare hostname still uses http://, and any other scheme throws InvalidArgumentException. setCaFile() trusts the CAs in a PEM file. For a self-signed Express certificate, pass the certificate itself. setVerifyTls(false) turns off certificate and hostname checks. It is meant for local testing only. --- src/BitGoExpress.php | 51 ++++++++++++++++++++--- tests/ExpressTlsTest.php | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/ExpressTlsTest.php diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 7761508..7ed858b 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -35,22 +35,60 @@ class BitGoExpress public string $coin; private int $timeout = self::DEFAULT_TIMEOUT; private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; + private ?string $caFile = null; + private bool $verifyTls = true; /** * 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; } /** @@ -628,14 +666,15 @@ private function curlOptions(string $requestType, array $params, bool $authentic $options[CURLOPT_CUSTOMREQUEST] = 'PUT'; $options[CURLOPT_POSTFIELDS] = json_encode($params); } - $options[CURLOPT_SSL_VERIFYPEER] = true; + $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] = dirname(__FILE__) . '/cacert.pem'; + $options[CURLOPT_CAINFO] = $this->caFile ?? dirname(__FILE__) . '/cacert.pem'; if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; } 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', []); + } +} From 26b7d832513f9f9ba40506317065470a4a6b5921 Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:47:12 +1030 Subject: [PATCH 6/7] Fix the coin lists that gate coin-specific params. The UTXO list included Stacks (stx, tstx), which is account-based, and left out Dogecoin and Bitcoin Gold testnet. Most other checks compared against the mainnet code only, so testnet wallets silently lost params: gasPrice, enterprise and the like on teth, feeTxConfirmTarget on tbtc, segwit on tltc, dt on txrp and memoId on txlm. Each check now uses a coin family that lists the mainnet and testnet codes. The ETH family also includes the Goerli (gteth) and Holesky (hteth) codes. generateWallet() also stops sending gasPrice: 0 for non-ETH coins. That 0 used to be filtered out, but since 0 values are sent, it reached BitGo. --- phpstan-baseline.neon | 6 -- src/BitGoExpress.php | 118 ++++++++++++++++++++++----------------- src/BitGoSDK.php | 45 ++++++++++----- tests/CoinFamilyTest.php | 87 +++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 72 deletions(-) create mode 100644 tests/CoinFamilyTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4813ca6..fa86f97 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -144,12 +144,6 @@ 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 diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 7ed858b..200a5a6 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -23,11 +23,17 @@ 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']; public $accessToken = null; public $walletId = null; public string $hostname; @@ -178,7 +184,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(); @@ -245,22 +251,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(); } @@ -297,22 +303,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(); } @@ -344,7 +350,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 @@ -384,7 +390,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(); } @@ -412,12 +418,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(); } @@ -445,18 +451,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); } @@ -695,6 +701,16 @@ private function rememberAccessToken($result): void } } + /** + * 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 ac4e6f9..139b51d 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -25,6 +25,11 @@ 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; @@ -253,7 +258,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'); } @@ -268,7 +273,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'); } @@ -297,7 +302,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'); } @@ -342,8 +347,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(); } @@ -358,8 +363,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'); } @@ -375,8 +380,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'); @@ -416,7 +421,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'); } @@ -553,8 +558,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(); } @@ -752,8 +757,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'); } @@ -784,7 +789,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'); } @@ -1045,6 +1050,16 @@ private function curlOptions(string $requestType, array $params): array 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/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(); + } +} From 33e73206b68ba0d0a36ae90756e2da705af9a93b Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 18:48:20 +1030 Subject: [PATCH 7/7] Stop forcing IPv4 on every request. Both clients set CURLOPT_IPRESOLVE to IPv4 unconditionally, which breaks IPv6-only hosts. cURL now uses IPv4 or IPv6 as the system allows. Call setForceIPv4(true) to get the old behaviour back. When BitGoSDK::setRequestIP() binds a local address, requests resolve to that address's IP version, since a bound address can't reach the other one. --- src/BitGoExpress.php | 13 ++++++++- src/BitGoSDK.php | 15 ++++++++++- tests/IpResolveTest.php | 58 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/IpResolveTest.php diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 200a5a6..2b8fbf4 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -43,6 +43,7 @@ class BitGoExpress private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; private ?string $caFile = null; private bool $verifyTls = true; + private bool $forceIPv4 = false; /** * BitGoExpress Initialization @@ -125,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. * @@ -681,7 +692,7 @@ private function curlOptions(string $requestType, array $params, bool $authentic } $options[CURLOPT_HTTPHEADER] = $headers; $options[CURLOPT_CAINFO] = $this->caFile ?? dirname(__FILE__) . '/cacert.pem'; - if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { + if ($this->forceIPv4) { $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; } $options[CURLOPT_TIMEOUT] = $this->timeout; diff --git a/src/BitGoSDK.php b/src/BitGoSDK.php index 139b51d..f1eb365 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -41,6 +41,7 @@ class BitGoSDK public bool $testNet; private int $timeout = self::DEFAULT_TIMEOUT; private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; + private bool $forceIPv4 = false; /** * BitGoSDK Initialization @@ -99,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 * @@ -1041,8 +1052,10 @@ private function curlOptions(string $requestType, array $params): array $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 (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { + if ($this->forceIPv4) { $options[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4; } $options[CURLOPT_TIMEOUT] = $this->timeout; 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]); + } +}