diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index ce50dd463..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,41 +0,0 @@ -# GitHub Copilot Instructions — Bitcoin Keeper - -## Agent Routing - -### change-engineer (custom agent) - -All code changes — bugs and features — must go through the **change-engineer** custom -agent defined in `.github/agents/change-engineer.agent.md`. - -**Do not make code changes directly.** When working on an issue or task: - -1. The change-engineer agent is already active if it was selected in the UI. - It handles the full OpenSpec workflow internally — you do not need to invoke it - via `skill("change-engineer")` or any other tool. That call will fail. - -2. The agent runs the following three steps in order. Each step is mandatory: - - **Propose** — `openspec new change ""`, generate artifacts, commit - - **Apply** — work through `tasks.md` tasks, commit code changes - - **Archive** — `openspec archive --yes`, open PR - -3. **Hard stop rule**: if the OpenSpec workflow cannot be started (CLI unavailable, - missing files, etc.), stop and report the blocker. Do not fall back to direct code - edits as a substitute for the workflow. Use the skill file fallbacks instead: - - Propose: `.github/skills/openspec-propose/SKILL.md` - - Apply: `.github/skills/openspec-apply-change/SKILL.md` - - Archive: `.github/skills/openspec-archive-change/SKILL.md` - -## Commit Conventions - -| Type | Format | -|-----------|-------------------------------------| -| Spec | `spec: add OpenSpec artifacts for ` | -| Feature | `feat: ` | -| Bug fix | `fix: closes #` | -| Archive | `chore: archive ` | - -## OpenSpec Config - -All project context, domain map, and hard rules are in `openspec/config.yaml`. -Every artifact and code change must comply with the `context:` and `rules:` blocks -defined there. diff --git a/DESIGN.md b/DESIGN.md index 03334a4a1..232a3c995 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -37,6 +37,7 @@ Agents must: - use existing theme tokens, styles, spacing, radius, typography, and colors from code where available - use existing navigation, card, button, list, modal, sheet, form, and warning patterns - build new UI from existing primitives where possible +- if an SVG is provided as input for a UI element, use that SVG in implementation unless a requirement explicitly says otherwise - avoid hardcoding one-off values unless there is no existing token or component pattern - keep feature-specific flow and copy decisions inside the relevant requirement spec - treat this document as design guidance, not business logic @@ -586,6 +587,7 @@ Rules: - do not put long multi-step flows inside a bottom sheet unless existing Keeper patterns already do this - use full screens for complex tasks - keep CTA placement predictable +- keep modal CTA text short and action-specific to prevent button text overflow # UI content and wording @@ -745,4 +747,4 @@ This DESIGN.md provides: When there is conflict: - product behavior follows the feature spec - implementation values follow the codebase -- visual judgment follows this DESIGN.md +- visual judgment follows this DESIGN.md \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 3aadd97f9..be427d4af 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { applicationId "io.hexawallet.keeper" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 594 - versionName "2.5.11" + versionCode 596 + versionName "2.5.12" missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'store', 'play' multiDexEnabled true diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dbe4ae0a1..54d25525d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3959,7 +3959,7 @@ SPEC CHECKSUMS: glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - hermes-engine: c35a887d0a1856e5c339a78517f0fb3357a135b5 + hermes-engine: aa404dd2f865314cd211641ddff59d06039dbcf1 HtmlToPdf: 6a9c28f54ec810d1d120a698a9b3c83e2bdb1672 libportal-ios: d9aa55474e2d5be8e38e96345dd37be34fda45b4 libportal-react-native: 91b6bec36f7e92a0bcc4e9d51d6ce3ee7c163728 diff --git a/ios/hexa_keeper.xcodeproj/project.pbxproj b/ios/hexa_keeper.xcodeproj/project.pbxproj index 2b9123e96..eff5bd0dd 100644 --- a/ios/hexa_keeper.xcodeproj/project.pbxproj +++ b/ios/hexa_keeper.xcodeproj/project.pbxproj @@ -749,7 +749,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 594; + CURRENT_PROJECT_VERSION = 596; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Y5TCB759QL; ENABLE_BITCODE = NO; @@ -851,7 +851,7 @@ "$(inherited)", "\"$(SRCROOT)\"", ); - MARKETING_VERSION = 2.5.11; + MARKETING_VERSION = 2.5.12; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -879,7 +879,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 594; + CURRENT_PROJECT_VERSION = 596; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Y5TCB759QL; HEADER_SEARCH_PATHS = ( @@ -980,7 +980,7 @@ "$(inherited)", "\"$(SRCROOT)\"", ); - MARKETING_VERSION = 2.5.11; + MARKETING_VERSION = 2.5.12; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1150,7 +1150,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 594; + CURRENT_PROJECT_VERSION = 596; DEVELOPMENT_TEAM = Y5TCB759QL; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Y5TCB759QL; ENABLE_BITCODE = NO; @@ -1253,7 +1253,7 @@ "$(PROJECT_DIR)", "\"$(SRCROOT)\"", ); - MARKETING_VERSION = 2.5.11; + MARKETING_VERSION = 2.5.12; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1281,7 +1281,7 @@ CODE_SIGN_ENTITLEMENTS = hexa_keeper_dev.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 594; + CURRENT_PROJECT_VERSION = 596; DEVELOPMENT_TEAM = Y5TCB759QL; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Y5TCB759QL; HEADER_SEARCH_PATHS = ( @@ -1383,7 +1383,7 @@ "$(PROJECT_DIR)", "\"$(SRCROOT)\"", ); - MARKETING_VERSION = 2.5.11; + MARKETING_VERSION = 2.5.12; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/hexa_keeper/Info.plist b/ios/hexa_keeper/Info.plist index 082b08e67..aab9ee98b 100644 --- a/ios/hexa_keeper/Info.plist +++ b/ios/hexa_keeper/Info.plist @@ -62,7 +62,7 @@ CFBundleVersion - 594 + 596 LSApplicationQueriesSchemes itms-apps diff --git a/ios/hexa_keeperTests/Info.plist b/ios/hexa_keeperTests/Info.plist index 8b99dcd0c..769e693ee 100644 --- a/ios/hexa_keeperTests/Info.plist +++ b/ios/hexa_keeperTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 594 + 596 diff --git a/ios/hexa_keeper_dev-Info.plist b/ios/hexa_keeper_dev-Info.plist index 8b73a438b..5a64dfa91 100644 --- a/ios/hexa_keeper_dev-Info.plist +++ b/ios/hexa_keeper_dev-Info.plist @@ -36,7 +36,7 @@ CFBundleVersion - 594 + 596 LSApplicationQueriesSchemes itms-apps diff --git a/package.json b/package.json index 77045a7b7..2ff4d02cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hexa_keeper", - "version": "2.5.11", + "version": "2.5.12", "private": true, "scripts": { "ios": "npx react-native run-ios --scheme=hexa_keeper_dev --simulator 'iPhone 16' ", diff --git a/patches/react-native-tcp-socket+6.4.1.patch b/patches/react-native-tcp-socket+6.4.1.patch new file mode 100644 index 000000000..4d8b9cc51 --- /dev/null +++ b/patches/react-native-tcp-socket+6.4.1.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/react-native-tcp-socket/android/src/main/java/com/asterinet/react/tcpsocket/TcpSocketClient.java b/node_modules/react-native-tcp-socket/android/src/main/java/com/asterinet/react/tcpsocket/TcpSocketClient.java +index e0f719f..6908996 100644 +--- a/node_modules/react-native-tcp-socket/android/src/main/java/com/asterinet/react/tcpsocket/TcpSocketClient.java ++++ b/node_modules/react-native-tcp-socket/android/src/main/java/com/asterinet/react/tcpsocket/TcpSocketClient.java +@@ -51,7 +51,6 @@ class TcpSocketClient extends TcpSocket { + // Get the addresses + final String localAddress = options.hasKey("localAddress") ? options.getString("localAddress") : "0.0.0.0"; + final InetAddress localInetAddress = InetAddress.getByName(localAddress); +- final InetAddress remoteInetAddress = InetAddress.getByName(address); + if (network != null) + network.bindSocket(socket); + // setReuseAddress +@@ -66,7 +65,8 @@ class TcpSocketClient extends TcpSocket { + // bind + socket.bind(new InetSocketAddress(localInetAddress, localPort)); + final int connectTimeout = options.hasKey("connectTimeout") ? options.getInt("connectTimeout") : 0; +- socket.connect(new InetSocketAddress(remoteInetAddress, port), connectTimeout); ++ // Keep the original hostname to let TLS stacks build SNI/peer identity correctly. ++ socket.connect(new InetSocketAddress(address, port), connectTimeout); + if (socket instanceof SSLSocket) ((SSLSocket) socket).startHandshake(); + startListening(); + } +@@ -74,7 +74,14 @@ class TcpSocketClient extends TcpSocket { + public void startTLS(Context context, ReadableMap tlsOptions) throws IOException, GeneralSecurityException { + if (socket instanceof SSLSocket) return; + SSLSocketFactory ssf = getSSLSocketFactory(context, tlsOptions); +- SSLSocket sslSocket = (SSLSocket) ssf.createSocket(socket, socket.getInetAddress().getHostAddress(), socket.getPort(), true); ++ String peerHost = socket.getInetAddress().getHostName(); ++ if (tlsOptions.hasKey("host")) { ++ String host = tlsOptions.getString("host"); ++ if (host != null && !host.isEmpty()) { ++ peerHost = host; ++ } ++ } ++ SSLSocket sslSocket = (SSLSocket) ssf.createSocket(socket, peerHost, socket.getPort(), true); + sslSocket.setUseClientMode(true); + sslSocket.startHandshake(); + socket = sslSocket; diff --git a/src/components/KeeperModal.tsx b/src/components/KeeperModal.tsx index 883226258..14fd7cc11 100644 --- a/src/components/KeeperModal.tsx +++ b/src/components/KeeperModal.tsx @@ -281,7 +281,6 @@ const getStyles = (subTitleWidth) => alignSelf: 'flex-start', borderBottomWidth: 0, backgroundColor: 'transparent', - width: '90%', marginTop: wp(5), }, bodyContainer: { diff --git a/src/components/KeeperQRCode.tsx b/src/components/KeeperQRCode.tsx index 45deb2b8d..6a386346f 100644 --- a/src/components/KeeperQRCode.tsx +++ b/src/components/KeeperQRCode.tsx @@ -1,6 +1,6 @@ import QRCode from 'react-native-qrcode-svg'; -import React from 'react'; -import { StyleSheet } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { Platform, StyleSheet } from 'react-native'; import { Box, useColorMode } from '@gluestack-ui/themed-native-base'; import { useSelector } from 'react-redux'; @@ -23,6 +23,16 @@ function KeeperQRCode({ const { colorMode } = useColorMode(); const themeMode = useSelector((state: any) => state?.settings?.themeMode); const privateTheme = themeMode === 'PRIVATE' || themeMode === 'PRIVATE_LIGHT'; + + // Workaround for react-native-svg not painting on initial mount with Fabric (new architecture) on iOS. + // Toggling the key forces a remount of the QRCode SVG after the first frame. + const [renderKey, setRenderKey] = useState(0); + useEffect(() => { + if (Platform.OS === 'ios') { + requestAnimationFrame(() => setRenderKey(1)); + } + }, []); + return ( {qrData && ( ('idle'); + const [recoveryKeyFlowState, setRecoveryKeyFlowState] = useState< + 'idle' | 'education' | 'skipWarning' + >('idle'); // Session flag: prevent re-showing the education sheet after the user dismisses it const hasShownEducationSheetRef = useRef(false); @@ -81,7 +82,11 @@ function NewHomeScreen({ route }) { useFocusEffect( React.useCallback(() => { - if (!isConfirmed && !hasShownEducationSheetRef.current && selectedOption !== walletText.more) { + if ( + !isConfirmed && + !hasShownEducationSheetRef.current && + selectedOption !== walletText.more + ) { const timer = setTimeout(() => { openEducationSheet(); }, 100); @@ -211,21 +216,12 @@ function NewHomeScreen({ route }) { const SkipWarningContent = () => ( - - {homeTranslation.skipWarningBody} - - + {homeTranslation.skipWarningBox} - + {homeTranslation.skipWarningInfoBox} @@ -243,14 +239,26 @@ function NewHomeScreen({ route }) { {recoveryKeyStatus === 'skipped' && ( - - } - primaryCallback={openEducationSheet} - /> - + + + + + + {homeTranslation.recoveryKeyNotBackedUp} + + + {homeTranslation.backUpNow} + + + + )} {content} setRecoveryKeyFlowState('education')} title={homeTranslation.skipWarningTitle} - subTitle={''} + subTitle={homeTranslation.skipWarningBody} subTitleColor={`${colorMode}.modalSubtitleBlack`} modalBackground={`${colorMode}.modalWhiteBackground`} textColor={`${colorMode}.textGreen`} diff --git a/src/screens/Home/components/Settings/keeperSettings.tsx b/src/screens/Home/components/Settings/keeperSettings.tsx index bdeec1e83..65b0c2843 100644 --- a/src/screens/Home/components/Settings/keeperSettings.tsx +++ b/src/screens/Home/components/Settings/keeperSettings.tsx @@ -166,6 +166,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: wp(10), justifyContent: 'center', + alignSelf: 'center', }, bottomLinkWrapper: { @@ -180,9 +181,10 @@ const styles = StyleSheet.create({ letterSpacing: 0.13, }, disclaimer: { - maxWidth: '99%', + maxWidth: '90%', fontSize: 11, textAlign: 'center', marginVertical: hp(10), + alignSelf: 'center', }, }); diff --git a/src/services/electrum/client.ts b/src/services/electrum/client.ts index 422d27f60..129db5a32 100644 --- a/src/services/electrum/client.ts +++ b/src/services/electrum/client.ts @@ -7,6 +7,10 @@ import torrific from './torrific'; import RestClient, { TorStatus } from '../rest/RestClient'; import ecc from '../wallets/operations/taproot-utils/noble_ecc'; import { store } from 'src/store/store'; +import { + classifyElectrumConnectionError, + ElectrumConnectionErrorType, +} from './errorClassification'; bitcoinJS.initEccLib(ecc); @@ -27,6 +31,11 @@ const ELECTRUM_CLIENT_DEFAULTS = { peers: [], }; +let lastConnectionError: { + type: ElectrumConnectionErrorType; + message: string; +} | null = null; + // eslint-disable-next-line import/no-mutable-exports export let ELECTRUM_CLIENT: { electrumClient: any; @@ -109,6 +118,7 @@ export default class ElectrumClient { node: ELECTRUM_CLIENT.activePeer.host, }); + lastConnectionError = null; ELECTRUM_CLIENT.isClientConnected = true; ELECTRUM_CLIENT.activePeer.isConnected = true; } @@ -116,7 +126,16 @@ export default class ElectrumClient { ELECTRUM_CLIENT.isClientConnected = false; if (ELECTRUM_CLIENT.activePeer) ELECTRUM_CLIENT.activePeer.isConnected = false; - console.log('Bad connection:', JSON.stringify(ELECTRUM_CLIENT.activePeer), error); + const errorType = classifyElectrumConnectionError(error); + const errorMessage = error?.message || String(error); + lastConnectionError = { + type: errorType, + message: errorMessage, + }; + console.log('Bad connection:', JSON.stringify(ELECTRUM_CLIENT.activePeer), { + errorType, + message: errorMessage, + }); } finally { if (timeoutId) clearTimeout(timeoutId); } @@ -139,12 +158,20 @@ export default class ElectrumClient { if (ELECTRUM_CLIENT.connectionAttempt >= ELECTRUM_CLIENT_CONFIG.maxConnectionAttempt) { const nextPeer = ElectrumClient.getNextPeer(); if (!nextPeer) { - console.log( - 'Unable to connect to any electrum server. Please switch network and try again!' - ); + const fallbackError = + 'Unable to connect to any electrum server. Please switch network and try again!'; + const error = lastConnectionError?.message || fallbackError; + const errorType = lastConnectionError?.type || 'network'; + + console.log('Unable to connect to any electrum server', { + errorType, + error, + }); + return { connected: ELECTRUM_CLIENT.isClientConnected, - error: 'Unable to connect to any electrum server. Please switch network and try again!', + error, + errorType, }; } @@ -425,6 +452,7 @@ export default class ElectrumClient { let timeoutId = null; let conncetionError = null; + let connectionErrorType: ElectrumConnectionErrorType | null = null; try { const ver = await Promise.race([ new Promise((resolve) => { @@ -437,16 +465,20 @@ export default class ElectrumClient { ]); if (ver === 'timeout') throw new Error('Connection time-out'); - if (ver && ver[0]) return { connected: true, error: conncetionError }; + if (ver && ver[0]) return { connected: true, error: conncetionError, errorType: null }; else throw new Error('failed to connect'); } catch (err) { - console.log({ err }); + connectionErrorType = classifyElectrumConnectionError(err); + console.log({ + err, + errorType: connectionErrorType, + }); conncetionError = err; } finally { if (timeoutId) clearTimeout(timeoutId); client.close(); } - return { connected: false, error: conncetionError }; + return { connected: false, error: conncetionError, errorType: connectionErrorType }; } } diff --git a/src/services/electrum/errorClassification.ts b/src/services/electrum/errorClassification.ts new file mode 100644 index 000000000..2e8bd51ed --- /dev/null +++ b/src/services/electrum/errorClassification.ts @@ -0,0 +1,36 @@ +export type ElectrumConnectionErrorType = 'tls-certificate' | 'network' | 'unknown'; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + + try { + return JSON.stringify(error); + } catch (_) { + return String(error ?? ''); + } +}; + +export const classifyElectrumConnectionError = ( + error: unknown +): ElectrumConnectionErrorType => { + const message = getErrorMessage(error).toLowerCase(); + + if ( + /(certpathvalidatorexception|trust anchor|certificate path|valid certification path|sslhandshakeexception|certificate verify|hostname.*(mismatch|verif))/.test( + message + ) + ) { + return 'tls-certificate'; + } + + if ( + /(timeout|timed out|econn|enotfound|network|unreachable|unable to connect|failed to connect|socket.*closed)/.test( + message + ) + ) { + return 'network'; + } + + return 'unknown'; +}; diff --git a/src/services/electrum/tls.js b/src/services/electrum/tls.js index 24cd82543..a7902e27e 100644 --- a/src/services/electrum/tls.js +++ b/src/services/electrum/tls.js @@ -12,11 +12,16 @@ import TcpSocket from 'react-native-tcp-socket'; * @constructor */ function connect(config, callback) { + const rejectUnauthorized = config.rejectUnauthorized !== false; + return TcpSocket.connectTLS( { port: config.port, host: config.host, - tlsCheckValidity: config.rejectUnauthorized, + // Keep both flags for compatibility across react-native-tcp-socket variants. + rejectUnauthorized, + tlsCheckValidity: rejectUnauthorized, + servername: config.servername || config.host, }, callback ); diff --git a/tests/services/client.error-classification.test.ts b/tests/services/client.error-classification.test.ts new file mode 100644 index 000000000..0412b326e --- /dev/null +++ b/tests/services/client.error-classification.test.ts @@ -0,0 +1,30 @@ +import assert from 'assert'; +import { classifyElectrumConnectionError } from '../../src/services/electrum/errorClassification'; + +describe('classifyElectrumConnectionError', () => { + it('classifies trust-anchor failures as tls-certificate', () => { + const error = new Error( + 'javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found' + ); + + assert.strictEqual(classifyElectrumConnectionError(error), 'tls-certificate'); + }); + + it('classifies timeout/unreachable failures as network', () => { + const error = new Error('Connection timed out while connecting to electrum host'); + + assert.strictEqual(classifyElectrumConnectionError(error), 'network'); + }); + + it('classifies unable-to-connect failures as network', () => { + const error = new Error('Unable to connect to any electrum server. Please switch network'); + + assert.strictEqual(classifyElectrumConnectionError(error), 'network'); + }); + + it('classifies unknown messages as unknown', () => { + const error = new Error('unexpected internal electrum failure'); + + assert.strictEqual(classifyElectrumConnectionError(error), 'unknown'); + }); +}); diff --git a/tests/services/tls-adapter.test.ts b/tests/services/tls-adapter.test.ts new file mode 100644 index 000000000..ef8b53d9e --- /dev/null +++ b/tests/services/tls-adapter.test.ts @@ -0,0 +1,63 @@ +jest.mock('react-native-tcp-socket', () => ({ + connectTLS: jest.fn(), +})); + +const TcpSocket = require('react-native-tcp-socket'); +const tlsAdapter = require('../../src/services/electrum/tls'); + +describe('electrum tls adapter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses strict TLS defaults and host as servername', () => { + const callback = jest.fn(); + const socket = { id: 'strict-socket' }; + TcpSocket.connectTLS.mockReturnValue(socket); + + const result = tlsAdapter.connect( + { + host: 'electrum.example.com', + port: 50002, + }, + callback + ); + + expect(TcpSocket.connectTLS).toHaveBeenCalledWith( + { + host: 'electrum.example.com', + port: 50002, + rejectUnauthorized: true, + tlsCheckValidity: true, + servername: 'electrum.example.com', + }, + callback + ); + expect(result).toBe(socket); + }); + + it('respects explicit rejectUnauthorized false and custom servername', () => { + const callback = jest.fn(); + + tlsAdapter.connect( + { + host: '127.0.0.1', + port: 50002, + rejectUnauthorized: false, + servername: 'node.example.com', + }, + callback + ); + + expect(TcpSocket.connectTLS).toHaveBeenCalledWith( + { + host: '127.0.0.1', + port: 50002, + rejectUnauthorized: false, + tlsCheckValidity: false, + servername: 'node.example.com', + }, + callback + ); + }); +});