diff --git a/.github/workflows/unit-tests-android.yml b/.github/workflows/unit-tests-android.yml new file mode 100644 index 00000000..881e9440 --- /dev/null +++ b/.github/workflows/unit-tests-android.yml @@ -0,0 +1,45 @@ +name: unit-test-android + +on: + pull_request: + branches: + - 'master' + push: + branches: + - 'master' + +jobs: + tests-android: + name: Android Unit Tests + runs-on: macos-14 # m1 mac + # runs-on: ubuntu-latest # locally via act + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + # Uncomment for locally running workflow via act + # - name: Install yarn + # run: npm install -g yarn + + - name: Install node_modules + working-directory: example + run: yarn install && yarn rn-setup + + - name: Use JDK 1.11 + uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '11.0.22' + architecture: x64 + + - name: Use Android SDK + uses: android-actions/setup-android@v2 + + - name: Run Android unit tests + run: ./gradlew testDebugUnitTest --stacktrace --no-daemon + working-directory: lib/android diff --git a/lib/.eslintignore b/lib/.eslintignore index 1abb93fa..8eae33d8 100644 --- a/lib/.eslintignore +++ b/lib/.eslintignore @@ -2,3 +2,5 @@ node_modules dist src/protos example +ios/* +android/* diff --git a/lib/android/.java-version b/lib/android/.java-version new file mode 100644 index 00000000..fe96d824 --- /dev/null +++ b/lib/android/.java-version @@ -0,0 +1 @@ +zulu64-11.0.22 diff --git a/lib/android/build.gradle b/lib/android/build.gradle index 49b8e817..985fbddb 100644 --- a/lib/android/build.gradle +++ b/lib/android/build.gradle @@ -6,17 +6,20 @@ buildscript { google() mavenCentral() jcenter() + maven { url = uri("https://plugins.gradle.org/m2/") } } dependencies { classpath 'com.android.tools.build:gradle:3.5.4' // noinspection DifferentKotlinGradleVersion classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath("com.adarshr:gradle-test-logger-plugin:2.0.0") } } apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: "com.adarshr.test-logger" def getExtOrDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['Ldk_' + name] @@ -128,4 +131,12 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" // implementation files('libs/ldk-java-javadoc.jar') // Used to get code completion in Kotlin but won't compile with it compileOnly files('libs/LDK-release.aar') + + // Unit testing dependencies + testImplementation project(':') + testImplementation files('libs/ldk-java.jar') // Needs jvm11 aarch64 for tests with LDK references + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito.kotlin:mockito-kotlin:3.2.0") + testImplementation("org.robolectric:robolectric:4.7.3") } diff --git a/lib/android/gradle/wrapper/gradle-wrapper.properties b/lib/android/gradle/wrapper/gradle-wrapper.properties index da9702f9..dca1748e 100644 --- a/lib/android/gradle/wrapper/gradle-wrapper.properties +++ b/lib/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Feb 29 16:57:26 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/lib/android/libs/ldk-java.jar b/lib/android/libs/ldk-java.jar new file mode 100644 index 00000000..9fe99b1d Binary files /dev/null and b/lib/android/libs/ldk-java.jar differ diff --git a/lib/android/src/main/java/com/reactnativeldk/Helpers.kt b/lib/android/src/main/java/com/reactnativeldk/Helpers.kt index a39b2ad2..f422e4fc 100644 --- a/lib/android/src/main/java/com/reactnativeldk/Helpers.kt +++ b/lib/android/src/main/java/com/reactnativeldk/Helpers.kt @@ -14,18 +14,18 @@ import java.util.Date fun handleResolve(promise: Promise, res: LdkCallbackResponses) { if (res != LdkCallbackResponses.log_write_success) { - LdkEventEmitter.send(EventTypes.native_log, "Success: ${res}") + LdkEventEmitter.send(EventTypes.native_log, "Success: $res") } - promise.resolve(res.toString()); + promise.resolve(res.name) } fun handleReject(promise: Promise, ldkError: LdkErrors, error: Error? = null) { - if (error !== null) { - LdkEventEmitter.send(EventTypes.native_log, "Error: ${ldkError}. Message: ${error.toString()}") - promise.reject(ldkError.toString(), error); + if (error != null) { + LdkEventEmitter.send(EventTypes.native_log, "Error: $ldkError. Message: ${error.message}") + promise.reject(ldkError.name, error) } else { - LdkEventEmitter.send(EventTypes.native_log, "Error: ${ldkError}") - promise.reject(ldkError.toString(), ldkError.toString()) + LdkEventEmitter.send(EventTypes.native_log, "Error: $ldkError") + promise.reject(ldkError.name, ldkError.name) } } @@ -383,9 +383,6 @@ fun ChannelConfig.mergeWithMap(map: ReadableMap?): ChannelConfig { return this } - try { - _forwarding_fee_base_msat = map.getInt("forwarding_fee_base_msat") - } catch (_: Exception) {} try { _forwarding_fee_base_msat = map.getInt("forwarding_fee_base_msat") } catch (_: Exception) {} diff --git a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt index 5d7f4816..fe7a65da 100644 --- a/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt +++ b/lib/android/src/main/java/com/reactnativeldk/LdkModule.kt @@ -1,5 +1,7 @@ package com.reactnativeldk +import android.os.Build +import androidx.annotation.RequiresApi import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter import com.reactnativeldk.classes.* @@ -111,6 +113,7 @@ enum class LdkCallbackResponses { add_peer_success, chain_sync_success, invoice_payment_success, + abandon_payment_success, tx_set_confirmed, tx_set_unconfirmed, process_pending_htlc_forwards_success, @@ -141,9 +144,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod LdkEventEmitter.setContext(reactContext) } - override fun getName(): String { - return "Ldk" - } + override fun getName() = "Ldk" //Zero config objects lazy loaded into memory when required private val feeEstimator: LdkFeeEstimator by lazy { LdkFeeEstimator() } @@ -183,7 +184,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod channelStoragePath = "" } - //Startup methods + //MARK: Startup methods @ReactMethod fun setAccountStoragePath(storagePath: String, promise: Promise) { @@ -204,7 +205,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod LdkModule.accountStoragePath = accountStoragePath.absolutePath LdkModule.channelStoragePath = channelStoragePath.absolutePath - handleResolve(promise, LdkCallbackResponses.keys_manager_init_success) + handleResolve(promise, LdkCallbackResponses.storage_path_set) } @ReactMethod @@ -212,8 +213,8 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod val logFile = File(path) try { - if (!logFile.parentFile.exists()) { - logFile.parentFile.mkdirs() + if (logFile.parentFile?.exists() == false) { + logFile.parentFile?.mkdirs() } } catch (e: Exception) { return handleReject(promise, LdkErrors.create_storage_dir_fail, Error(e)) @@ -259,7 +260,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod @ReactMethod fun initUserConfig(userConfig: ReadableMap, promise: Promise) { - if (this.userConfig !== null) { + if (this.userConfig != null) { return handleReject(promise, LdkErrors.already_init) } @@ -268,40 +269,6 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod handleResolve(promise, LdkCallbackResponses.config_init_success) } - @ReactMethod - fun downloadScorer(scorerSyncUrl: String, skipHoursThreshold: Double, promise: Promise) { - val scorerFile = File(accountStoragePath + "/" + LdkFileNames.Scorer.fileName) - //If old one is still recent, skip download. Else delete it. - if (scorerFile.exists()) { - val lastModifiedHours = (System.currentTimeMillis().toDouble() - scorerFile.lastModified().toDouble()) / 1000 / 60 / 60 - if (lastModifiedHours < skipHoursThreshold) { - LdkEventEmitter.send(EventTypes.native_log, "Skipping scorer download. Last updated $lastModifiedHours hours ago.") - return handleResolve(promise, LdkCallbackResponses.scorer_download_skip) - } - - scorerFile.delete() - } - - Thread(Runnable { - val destinationFile = accountStoragePath + "/" + LdkFileNames.Scorer.fileName - - URL(scorerSyncUrl).downloadFile(destinationFile) { error -> - if (error != null) { - UiThreadUtil.runOnUiThread { - handleReject(promise, LdkErrors.scorer_download_fail, Error(error)) - } - return@downloadFile - } - - UiThreadUtil.runOnUiThread { - LdkEventEmitter.send(EventTypes.native_log, "Scorer downloaded successfully.") - handleResolve(promise, LdkCallbackResponses.scorer_download_success) - } - return@downloadFile - } - }).start() - } - @ReactMethod fun initNetworkGraph(network: String, rapidGossipSyncUrl: String, skipHoursThreshold: Double, promise: Promise) { if (networkGraph != null) { @@ -353,7 +320,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod LdkEventEmitter.send(EventTypes.native_log, "Rapid gossip sync fail. " + error.localizedMessage) //Temp fix for when a RGS server is changed or reset - if (error.localizedMessage.contains("LightningError")) { + if (error.localizedMessage?.contains("LightningError") == true) { if (networkGraphFile.exists()) { networkGraphFile.delete() } @@ -383,26 +350,59 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod } @ReactMethod + fun downloadScorer(scorerSyncUrl: String, skipHoursThreshold: Double, promise: Promise) { + val scorerFile = File(accountStoragePath + "/" + LdkFileNames.Scorer.fileName) + //If old one is still recent, skip download. Else delete it. + if (scorerFile.exists()) { + val lastModifiedHours = (System.currentTimeMillis().toDouble() - scorerFile.lastModified().toDouble()) / 1000 / 60 / 60 + if (lastModifiedHours < skipHoursThreshold) { + LdkEventEmitter.send(EventTypes.native_log, "Skipping scorer download. Last updated $lastModifiedHours hours ago.") + return handleResolve(promise, LdkCallbackResponses.scorer_download_skip) + } + + scorerFile.delete() + } + + Thread { + val destinationFile = accountStoragePath + "/" + LdkFileNames.Scorer.fileName + + URL(scorerSyncUrl).downloadFile(destinationFile) { error -> + if (error != null) { + UiThreadUtil.runOnUiThread { + handleReject(promise, LdkErrors.scorer_download_fail, Error(error)) + } + return@downloadFile + } + + UiThreadUtil.runOnUiThread { + LdkEventEmitter.send(EventTypes.native_log, "Scorer downloaded successfully.") + handleResolve(promise, LdkCallbackResponses.scorer_download_success) + } + return@downloadFile + } + }.start() + } + + @ReactMethod + @RequiresApi(Build.VERSION_CODES.O) fun initChannelManager(network: String, blockHash: String, blockHeight: Double, promise: Promise) { - if (channelManager !== null) { + // Guards to ensure properly initialized so far + if (channelManager != null) { return handleReject(promise, LdkErrors.already_init) } keysManager ?: return handleReject(promise, LdkErrors.init_keys_manager) userConfig ?: return handleReject(promise, LdkErrors.init_user_config) networkGraph ?: return handleReject(promise, LdkErrors.init_network_graph) - if (accountStoragePath == "") { - return handleReject(promise, LdkErrors.init_storage_path) - } - if (channelStoragePath == "") { + + if (accountStoragePath == "" || channelStoragePath == "") { return handleReject(promise, LdkErrors.init_storage_path) } ldkNetwork = getNetwork(network).first ldkCurrency = getNetwork(network).second - val enableP2PGossip = rapidGossipSync == null - + // Load channel manager from disk if it exists var channelManagerSerialized: ByteArray? = null val channelManagerFile = File(accountStoragePath + "/" + LdkFileNames.ChannelManager.fileName) if (channelManagerFile.exists()) { @@ -433,24 +433,22 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod ) try { + val entropySource = keysManager!!.inner.as_EntropySource() + val nodeSigner = keysManager!!.inner.as_NodeSigner() + val signerProvider = SignerProvider.new_impl(keysManager!!.signerProvider) + if (channelManagerSerialized != null) { - //Restoring node + // Restoring node LdkEventEmitter.send(EventTypes.native_log, "Restoring node from disk") - val channelMonitors: MutableList = arrayListOf() - Files.walk(Paths.get(channelStoragePath)) - .filter { Files.isRegularFile(it) } - .forEach { - LdkEventEmitter.send(EventTypes.native_log, "Loading channel from file " + it.fileName) - channelMonitors.add(it.toFile().readBytes()) - } + val channelMonitors = readChannelMonitorsFromStorage() channelManagerConstructor = ChannelManagerConstructor( channelManagerSerialized, - channelMonitors.toTypedArray(), + channelMonitors, userConfig!!, - keysManager!!.inner.as_EntropySource(), - keysManager!!.inner.as_NodeSigner(), - SignerProvider.new_impl(keysManager!!.signerProvider), + entropySource, + nodeSigner, + signerProvider, feeEstimator.feeEstimator, chainMonitor!!, filter.filter, @@ -463,16 +461,16 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod logger.logger ) } else { - //New node + // New node LdkEventEmitter.send(EventTypes.native_log, "Creating new channel manager") channelManagerConstructor = ChannelManagerConstructor( ldkNetwork, userConfig, blockHash.hexa().reversedArray(), blockHeight.toInt(), - keysManager!!.inner.as_EntropySource(), - keysManager!!.inner.as_NodeSigner(), - SignerProvider.new_impl(keysManager!!.signerProvider), + entropySource, + nodeSigner, + signerProvider, feeEstimator.feeEstimator, chainMonitor, networkGraph!!, @@ -491,9 +489,10 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod LogFile.write("Node ID: ${channelManager!!._our_node_id.hexEncodedString()}") + val enableP2PGossip = rapidGossipSync == null channelManagerConstructor!!.chain_sync_completed(channelManagerPersister, enableP2PGossip) - peerManager = channelManagerConstructor!!.peer_manager + peerManager = channelManagerConstructor!!.peer_manager peerHandler = channelManagerConstructor!!.nio_peer_handler //Cached for restarts @@ -503,7 +502,24 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod handleResolve(promise, LdkCallbackResponses.channel_manager_init_success) } + + @RequiresApi(Build.VERSION_CODES.O) + private fun readChannelMonitorsFromStorage(): Array { + val channelMonitors = mutableListOf() + Files.walk(Paths.get(channelStoragePath)) + .filter { Files.isRegularFile(it) } + .forEach { + LdkEventEmitter.send( + EventTypes.native_log, + "Loading channel from file " + it.fileName + ) + channelMonitors.add(it.toFile().readBytes()) + } + return channelMonitors.toTypedArray() + } + @ReactMethod + @RequiresApi(Build.VERSION_CODES.O) fun restart(promise: Promise) { if (channelManagerConstructor == null) { return handleReject(promise, LdkErrors.init_channel_manager) @@ -526,15 +542,15 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod LdkEventEmitter.send(EventTypes.native_log, "Starting LDK background tasks again") val initPromise = PromiseImpl( - { resolve -> + { LdkEventEmitter.send(EventTypes.channel_manager_restarted, "") LdkEventEmitter.send(EventTypes.native_log, "LDK restarted successfully") handleResolve(promise, LdkCallbackResponses.ldk_restart) - }, + }, { reject -> LdkEventEmitter.send(EventTypes.native_log, "Error restarting LDK. Error: $reject") handleReject(promise, LdkErrors.unknown_error) - }) + }) initChannelManager( currentNetwork, @@ -614,13 +630,12 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod val confirmTxData: MutableList = ArrayList() - var msg = "" txData.toArrayList().iterator().forEach { tx -> val txMap = tx as HashMap<*, *> confirmTxData.add( TwoTuple_usizeTransactionZ.of( - (txMap.get("pos") as Double).toLong(), - (txMap.get("transaction") as String).hexa() + (txMap["pos"] as Double).toLong(), + (txMap["transaction"] as String).hexa() ) ) } @@ -677,7 +692,12 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod fun closeChannel(channelId: String, counterpartyNodeId: String, force: Boolean, promise: Promise) { channelManager ?: return handleReject(promise, LdkErrors.init_channel_manager) - val res = if (force) channelManager!!.force_close_broadcasting_latest_txn(channelId.hexa(), counterpartyNodeId.hexa()) else channelManager!!.close_channel(channelId.hexa(), counterpartyNodeId.hexa()) + val res = + if (force) channelManager!!.force_close_broadcasting_latest_txn( + channelId.hexa(), + counterpartyNodeId.hexa() + ) + else channelManager!!.close_channel(channelId.hexa(), counterpartyNodeId.hexa()) if (!res.is_ok) { return handleReject(promise, LdkErrors.channel_close_fail) } @@ -716,7 +736,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod outputs.toArrayList().iterator().forEach { output -> val outputMap = output as HashMap<*, *> ldkOutputs.add( - TxOut((outputMap.get("value") as Double).toLong(), (outputMap.get("script_pubkey") as String).hexa()) + TxOut((outputMap["value"] as Double).toLong(), (outputMap["script_pubkey"] as String).hexa()) ) } @@ -818,7 +838,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod return handleReject(promise, LdkErrors.invoice_payment_fail_path_parameter_error, Error(paymentPartialFailure.toString())) } - return handleReject(promise, LdkErrors.invoice_payment_fail_sending, Error("PaymentError.Sending")) + return handleReject(promise, LdkErrors.invoice_payment_fail_sending, Error("PaymentError.Sending: ${sendingError.sending.name}")) } return handleReject(promise, LdkErrors.invoice_payment_fail_unknown) @@ -829,6 +849,8 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod channelManager ?: return handleReject(promise, LdkErrors.init_channel_manager) channelManager!!.abandon_payment(paymentId.hexa()) + + handleResolve(promise, LdkCallbackResponses.abandon_payment_success) } @ReactMethod @@ -925,6 +947,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod } @ReactMethod + @RequiresApi(Build.VERSION_CODES.O) fun listChannelFiles(promise: Promise) { if (channelStoragePath == "") { return handleReject(promise, LdkErrors.init_storage_path) @@ -1007,8 +1030,6 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod channelManager.list_channels() else arrayOf() - - promise.resolve(chainMonitor.getClaimableBalancesAsJson(ignoredChannels)) } @@ -1213,7 +1234,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod } channelManagerConstructor?.net_graph?._last_rapid_gossip_sync_timestamp?.let { res -> - val syncTimestamp = if (res is Option_u32Z.Some) (res as Option_u32Z.Some).some.toLong() else 0 + val syncTimestamp = if (res is Option_u32Z.Some) res.some.toLong() else 0 if (syncTimestamp == 0L) { logDump.add("Last rapid gossip sync time: NEVER") } else { @@ -1248,7 +1269,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod promise.resolve(logString) } - //Backup methods + //MARK: Backup methods @ReactMethod fun backupSetup(seed: String, network: String, server: String, serverPubKey: String, promise: Promise) { val seedBytes = seed.hexa() @@ -1407,6 +1428,6 @@ object LdkEventEmitter { return } - this.reactContext!!.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventType.toString(), body) + this.reactContext!!.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventType.name, body) } } diff --git a/lib/android/src/main/java/com/reactnativeldk/classes/LdkLogger.kt b/lib/android/src/main/java/com/reactnativeldk/classes/LdkLogger.kt index 9a4d56d3..d7060560 100644 --- a/lib/android/src/main/java/com/reactnativeldk/classes/LdkLogger.kt +++ b/lib/android/src/main/java/com/reactnativeldk/classes/LdkLogger.kt @@ -23,8 +23,8 @@ private fun levelString(level: Int): String { } class LdkLogger { - var activeLevels = HashMap() - var logger = Logger.new_impl{ record: Record -> + private var activeLevels = HashMap() + var logger: Logger = Logger.new_impl { record: Record -> val level = levelString(record._level.ordinal) if (activeLevels[level] == true) { @@ -35,8 +35,8 @@ class LdkLogger { } fun setLevel(level: String, active: Boolean) { - activeLevels[level] = active; - LdkEventEmitter.send(EventTypes.native_log, "Log level ${level} set to ${active}") + activeLevels[level] = active + LdkEventEmitter.send(EventTypes.native_log, "Log level $level set to $active") } } diff --git a/lib/android/src/test/java/com/reactnativeldk/LdkModuleTest.kt b/lib/android/src/test/java/com/reactnativeldk/LdkModuleTest.kt new file mode 100644 index 00000000..3a323ac8 --- /dev/null +++ b/lib/android/src/test/java/com/reactnativeldk/LdkModuleTest.kt @@ -0,0 +1,762 @@ +package com.reactnativeldk + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter +import com.reactnativeldk.testutils.ShadowArguments +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.ldk.impl.bindings.get_ldk_version +import org.mockito.kotlin.any +import org.mockito.kotlin.check +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.isA +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val FS_ROOT = "build/test-files" + +private val INVOICE_EXPIRED = + "lnbcrt120n1pjlrxz9pp599l2jlsrt4qeczdyh90rzhfsfrkxhu6dged8yhfyhw0kt3sfzsyqdqdw4hxjaz5v4ehgcqzzsxqyz5vqsp54ehunpcenrejgq4tfwr8a4s5tlyrchhk27e70a0vagwr60n8mwhq9qyyssq5t6dqjwu4r6rnjfz7uk9mx0p2h2rxlr00fqyzjtudglay6lzfqp370ml6y8hnwfz8eamhdt4nu7s6jwy64gd9gtl9t8zz5l67cq6j6qqkgmr5v" + +@Config(shadows = [ShadowArguments::class], manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class LdkModuleTest { + private val eventEmitter = mock() + private val reactContext = mock { + on { getJSModule(RCTDeviceEventEmitter::class.java) } doReturn eventEmitter + } + + private val randomSeed = "fcab948ff9fdc573d53901ce825b4999307af9dbe36b9d2afce75f2edcb11d2d" + private val backupServer = "http://0.0.0.0:3003" + private val backupServerPubKey = "serverPubKey" + private val regtest = "regtest" + + private val accountStoragePath = "$FS_ROOT/wallet0" + private val logsPath = "$accountStoragePath/logs/ldk.log" + + private val blockHash = "207b2b781f08be676158d834393c4ae7615e5ea9c8740ca046fc948d2ae1da6f" + private val blockHeight = 21795.0 + + private lateinit var _promise: Promise + private lateinit var ldkModule: LdkModule + + @Before + fun setUp() { + ldkModule = LdkModule(reactContext) + _promise = mock() + // TODO remove after PR gets merged and LDK is updated: + // https://github.com/lightningdevkit/ldk-garbagecollected/issues/149 + get_ldk_version() // ← HACK to force load LDK bindings + // TODO clear tests file storage (maybe?) + } + + // MARK: Startup methods + + @Test + fun test_init() { + assertTrue { ldkModule.name == "Ldk" } + assertTrue { LdkModule.accountStoragePath == "" } + assertTrue { LdkModule.channelStoragePath == "" } + } + + @Test + fun test_setAccountStoragePath() { + val promise = mock() + + ldkModule.setAccountStoragePath(accountStoragePath, promise) + + assertTrue { LdkModule.accountStoragePath.endsWith(accountStoragePath) } + assertTrue { LdkModule.channelStoragePath.contains(accountStoragePath) } + verify(promise).resolve(LdkCallbackResponses.storage_path_set.name) + } + + @Test + fun test_setLogFilePath() { + val promise = mock() + + ldkModule.setLogFilePath(logsPath, promise) + + verify(promise).resolve(LdkCallbackResponses.log_path_updated.name) + } + + @Test + fun test_writeToLogFile() { + // TODO Refactor to remove file reading in tests + val line = "test" + ldkModule.setLogFilePath(logsPath, _promise) + + ldkModule.writeToLogFile(line, _promise) + + assertTrue { File(logsPath).readText().endsWith("$line\n") } + } + + @Test + fun test_initKeysManager() { + // TODO Test error cases + initKeysManager() + + verify(_promise).resolve(LdkCallbackResponses.keys_manager_init_success.name) + } + + @Test + fun test_initUserConfig() { + // TODO Extend to check expected configs are set + ldkModule.initUserConfig(mock(), _promise) + + verify(_promise).resolve(LdkCallbackResponses.config_init_success.name) + } + + @Test + fun test_initNetworkGraph() { + // TODO Extend + val promise = mock() + initStorage() + + initNetworkGraph(promise) + + verify(promise).resolve(LdkCallbackResponses.network_graph_init_success.name) + } + + @Ignore("No downloads in tests") + @Test + fun test_downloadScorer() { + ldkModule.downloadScorer("url", 0.1, _promise) + + verify(_promise).resolve(LdkCallbackResponses.scorer_download_success.name) + } + + @Test + fun test_initChannelManager() { + // TODO extend to check other exit points + val promise = mock() + initStorage() + initKeysManager() + initUserConfig() + initNetworkGraph(promise) + + ldkModule.initChannelManager(regtest, blockHash, blockHeight, promise) + + verify(promise).resolve(LdkCallbackResponses.channel_manager_init_success.name) + } + + @Test + fun test_restart() { + // TODO extend to check non-happy flows + val promise = mock() + setupChannelManager() + + ldkModule.restart(promise) + + verify(eventEmitter).emit(eq(EventTypes.channel_manager_restarted.name), any()) + verify(promise).resolve(LdkCallbackResponses.ldk_restart.name) + } + + @Test + fun test_stop() { + val promise = mock() + setupChannelManager() + + ldkModule.stop(promise) + + verify(promise).resolve(LdkCallbackResponses.ldk_stop.name) + } + + // MARK: Update methods + + @Test + fun test_updateFees() { + val promise = mock() + + ldkModule.updateFees( + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 5.0, + 6.0, + promise + ) + + verify(promise).resolve(LdkCallbackResponses.fees_updated.name) + } + + @Test + fun test_setLogLevel() { + ldkModule.setLogLevel("INFO", true, _promise) + ldkModule.setLogLevel("WARN", true, _promise) + ldkModule.setLogLevel("ERROR", true, _promise) + ldkModule.setLogLevel("DEBUG", true, _promise) + + verify(_promise, times(4)).resolve(LdkCallbackResponses.log_level_updated.name) + } + + @Test + fun test_syncToTip() { + // TODO extend to check non-happy flows + val promise = mock() + val newHeader = + "00000020f37e0babff84b49ebe154a828220a9964b4a4013e7293b48c7f9d5bc0323f464569f4f647e27085c9f2dab72c710f55b5d43fdf0ffc9ef63b3759427afbfe94981a8ec65ffff7f2001000000" + val newHeight = 21806.0 + val newBlockHash = "207b2b781f08be676158d834393c4ae7615e5ea9c8740ca046fc948d2ae1da6f" + setupChannelManager() + + ldkModule.syncToTip(newHeader, newBlockHash, newHeight, promise) + + verify(promise).resolve(LdkCallbackResponses.chain_sync_success.name) + } + + @Test + @Ignore("No networking in tests") + fun test_addPeer() { + val promise = mock() + setupChannelManager() + + val pubKey = "027b2b7158f8f4995629eaa7710aa06bb0d1d9d53adfd6dfff60a91314726f352b" + ldkModule.addPeer( + address = "127.0.0.1", + port = 9735.0, + pubKey = pubKey, + timeout = 1.0, + promise + ) + + verify(promise).resolve(LdkCallbackResponses.add_peer_success.name) + } + + @Test + fun test_setTxConfirmed() { + val promise = mock() + // TODO add txData to test, if figured out how to get Rust code not to panic + val newHeader = + "00000020f37e0babff84b49ebe154a828220a9964b4a4013e7293b48c7f9d5bc0323f464569f4f647e27085c9f2dab72c710f55b5d43fdf0ffc9ef63b3759427afbfe94981a8ec65ffff7f2001000000" + val newHeight = 21806.0 + // val txArrayList = arrayListOf( + // hashMapOf( + // "transaction" to "7433e396972882e249b1c1773919ed4ebbaccf88f89277b18a745db883e3a7da", + // "pos" to 0.0 + // ) + // ) + val txData = mock { + // on { toArrayList() } doReturn txArrayList + } + setupChannelManager() + + ldkModule.setTxConfirmed(newHeader, txData, newHeight, promise) + + verify(promise).resolve(LdkCallbackResponses.tx_set_confirmed.name) + } + + @Test + fun test_setTxUnconfirmed() { + val promise = mock() + val txId = "7433e396972882e249b1c1773919ed4ebbaccf88f89277b18a745db883e3a7da" + setupChannelManager() + + ldkModule.setTxUnconfirmed(txId, promise) + + verify(promise).resolve(LdkCallbackResponses.tx_set_unconfirmed.name) + } + + @Test + @Ignore("Probably too complex for unit tests?! Current err: 'Can't find a peer matching the passed counterparty node_id'") + fun test_acceptChannel() { + // TODO test error cases + val promise = mock() + // val pubKey = "027b2b7158f8f4995629eaa7710aa06bb0d1d9d53adfd6dfff60a91314726f352b" + // ldkModule.addPeer(address = "127.0.0.1", port = 9735.0, pubKey = pubKey, timeout = 1.0, promise) + val temporaryChannelId = "54b490a65d999b63c5a1f3abb0a429e45a51dfffc4dbdcca5f158f034b546652" + val counterPartyNodeId = + "027b2b7158f8f4995629eaa7710aa06bb0d1d9d53adfd6dfff60a91314726f352b" + val trustedPeer0Conf = true + setupChannelManager() + + ldkModule.acceptChannel(temporaryChannelId, counterPartyNodeId, trustedPeer0Conf, promise) + + verify(promise).resolve(LdkCallbackResponses.accept_channel_success.name) + } + + @Test + @Ignore("Probably too complex for unit tests?! Current err: 'Can't find a peer matching the passed counterparty node_id'") + fun test_closeChannel() { + // TODO test error cases + val promise = mock() + // val pubKey = "027b2b7158f8f4995629eaa7710aa06bb0d1d9d53adfd6dfff60a91314726f352b" + // ldkModule.addPeer(address = "127.0.0.1", port = 9735.0, pubKey = pubKey, timeout = 1.0, promise) + val channelId = "f0f0d39c66b7fda6cc4707abcf84144fa01ffc9ac3f4b6b6561f6ad030129435" + val counterPartyNodeId = + "027b2b7158f8f4995629eaa7710aa06bb0d1d9d53adfd6dfff60a91314726f352b" + setupChannelManager() + + ldkModule.closeChannel(channelId, counterPartyNodeId, false, _promise) + + verify(promise).resolve(LdkCallbackResponses.close_channel_success.name) + } + + @Test + fun test_forceCloseAllChannels() { + val promise = mock() + setupChannelManager() + + ldkModule.forceCloseAllChannels(false, promise) + + verify(promise).resolve(LdkCallbackResponses.close_channel_success.name) + } + + @Ignore("Too complex for unit tests?! parked for now") + @Test + fun test_spendOutputs() { + setupChannelManager() + ldkModule.spendOutputs(mock(), mock(), "script", 1.0, _promise) + } + + // MARK: Payments + + @Test + fun test_decode() { + ldkModule.decode(INVOICE_EXPIRED, _promise) + + verify(_promise).resolve(check { + // Schema + assertTrue { it.hasKey("description") } + assertTrue { it.hasKey("check_signature") } + assertTrue { it.hasKey("is_expired") } + assertTrue { it.hasKey("duration_since_epoch") } + assertTrue { it.hasKey("expiry_time") } + assertTrue { it.hasKey("min_final_cltv_expiry") } + assertTrue { it.hasKey("payee_pub_key") } + assertTrue { it.hasKey("recover_payee_pub_key") } + assertTrue { it.hasKey("payment_hash") } + assertTrue { it.hasKey("payment_secret") } + assertTrue { it.hasKey("timestamp") } + assertTrue { it.hasKey("features") } + assertTrue { it.hasKey("currency") } + assertTrue { it.hasKey("to_str") } + assertTrue { it.hasKey("route_hints") } + // Values + assertTrue { it.getString("description") == "unitTest" } + assertTrue { it.getDouble("amount_satoshis") == 12.0 } + assertTrue { it.getString("to_str") == INVOICE_EXPIRED } + }) + } + + @Test + fun test_pay_expiredInvoice() { + // TODO: Add test for happy flow + other error cases + setupChannelManager() + + ldkModule.pay(INVOICE_EXPIRED, 0.0, 2.5, _promise) + + verify(_promise).reject( + eq(LdkErrors.invoice_payment_fail_sending.name), + check { + assertEquals( + "PaymentError.Sending: LDKRetryableSendFailure_PaymentExpired", + it.message + ) + } + ) + } + + @Test + fun test_abandonPayment() { + val paymentId = "297ea97e035d419c09a4b95e315d3048ec6bf34d465a725d24bb9f65c6091408" + setupChannelManager() + + ldkModule.abandonPayment(paymentId, _promise) + + verify(_promise).resolve(LdkCallbackResponses.abandon_payment_success.name) + } + + @Test + fun test_createPaymentRequest() { + val promise = mock() + setupChannelManager() + + ldkModule.createPaymentRequest(11.0, "test", 3600.0, promise) + + verify(promise).resolve(check { + // Schema + assertTrue { it.hasKey("description") } + assertTrue { it.hasKey("check_signature") } + assertTrue { it.hasKey("is_expired") } + assertTrue { it.hasKey("duration_since_epoch") } + assertTrue { it.hasKey("expiry_time") } + assertTrue { it.hasKey("min_final_cltv_expiry") } + assertTrue { it.hasKey("payee_pub_key") } + assertTrue { it.hasKey("recover_payee_pub_key") } + assertTrue { it.hasKey("payment_hash") } + assertTrue { it.hasKey("payment_secret") } + assertTrue { it.hasKey("timestamp") } + assertTrue { it.hasKey("features") } + assertTrue { it.hasKey("currency") } + assertTrue { it.hasKey("to_str") } + assertTrue { it.hasKey("route_hints") } + // Values + assertTrue { it.getString("description") == "test" } + }) + } + + @Test + fun test_processPendingHtlcForwards() { + val promise = mock() + setupChannelManager() + + ldkModule.processPendingHtlcForwards(promise) + + verify(promise).resolve(LdkCallbackResponses.process_pending_htlc_forwards_success.name) + } + + @Test + fun test_claimFunds() { + val promise = mock() + val paymentPreImage = "297ea97e035d419c09a4b95e315d3048ec6bf34d465a725d24bb9f65c6091408" + setupChannelManager() + + ldkModule.claimFunds(paymentPreImage, promise) + + verify(promise).resolve(LdkCallbackResponses.claim_funds_success.name) + } + + // MARK: Fetch methods + @Test + fun test_version() { + ldkModule.version(_promise) + + verify(_promise).resolve(check { + assertTrue { it.contains("c_bindings") } + assertTrue { it.contains("ldk") } + }) + } + + @Test + fun test_nodeId() { + val promise = mock() + + setupChannelManager() + + ldkModule.nodeId(promise) + + verify(promise).resolve(check { + assertTrue { it.length == 66 } + }) + } + + @Test + fun test_listPeers() { + val promise = mock() + setupChannelManager() + + ldkModule.listPeers(promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_listChannels() { + val promise = mock() + setupChannelManager() + + ldkModule.listChannels(promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_listUsableChannels() { + val promise = mock() + setupChannelManager() + + ldkModule.listUsableChannels(promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_listChannelFiles() { + val promise = mock() + initStorage() + + ldkModule.listChannelFiles(promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_networkGraphListNodeIds() { + val promise = mock() + initNetworkGraph() + + ldkModule.networkGraphListNodeIds(promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_networkGraphNodes() { + val promise = mock() + initNetworkGraph() + + ldkModule.networkGraphNodes(JavaOnlyArray(), promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_networkGraphListChannels() { + val promise = mock() + initNetworkGraph() + + ldkModule.networkGraphListChannels(promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_networkGraphChannel() { + val promise = mock() + initNetworkGraph() + + ldkModule.networkGraphChannel("1", promise) + + verify(promise).resolve(isNull()) + } + + @Test + fun test_claimableBalances() { + // TODO: test happy flow w/ claimable balances + val promise = mock() + setupChannelManager() + + ldkModule.claimableBalances(true, promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_writeToFile() { + val promise = mock() + val fileName = "test-file.txt" + val content = "test" + initStorage() + + ldkModule.writeToFile(fileName, "", content, "", false, promise) + + verify(promise).resolve(LdkCallbackResponses.file_write_success.name) + } + + @Test + fun test_readFromFile() { + val promise = mock() + val fileName = "test-file.txt" + val content = "test" + initStorage() + ldkModule.writeToFile(fileName, "", content, "", false, this._promise) + + ldkModule.readFromFile(fileName, "", "", promise) + + verify(promise).resolve(check { + assertTrue { it.hasKey("content") } + assertTrue { it.hasKey("timestamp") } + assertTrue { it.getString("content") == content } + }) + } + + // MARK: Misc methods + + @Test + fun test_reconstructAndSpendOutputs() { + // TODO: extend + val promise = mock() + val outTxId = "7433e396972882e249b1c1773919ed4ebbaccf88f89277b18a745db883e3a7da" + val outIndex = 0.0 + val outValue = 1.0 + val changeDestScript = "03a6406c95e3df5300a7bf7fbd86352fb39db94a52ffae2b12feafe0b3f0aab51c" + val outPubKey = "0b6bd267533b7b8883e8690ad9b951d8f0642c23" + val feeRate = 1.0 + setupChannelManager() + + ldkModule.reconstructAndSpendOutputs( + outPubKey, + outValue, + outTxId, + outIndex, + feeRate, + changeDestScript, + promise + ) + + verify(promise).resolve(check { + assertTrue { it.length == 24 } + }) + } + + @Test + fun test_spendRecoveredForceCloseOutputs() { + // TODO: extend + val promise = mock() + setupChannelManager() + + ldkModule.spendRecoveredForceCloseOutputs("transaction", 1.0, "changeDestinationScript", promise) + + verify(promise).resolve(check { + assertTrue { it.size() == 0 } + }) + } + + @Test + fun test_nodeSign() { + val message = "test" + val expected = + "d6f89jkci9emiq47kt3g4m1k5zog3fyz15ga6jaoorxgsge4o589yh1wj4tjx4qtew19oke4rtdkw39ze7kh1ixmbcz8sxzrkq4u5n9q" + val promise = mock() + initKeysManager() + + ldkModule.nodeSign(message, promise) + + verify(promise).resolve(expected) + } + + @Test + fun test_nodeStateDump() { + val promise = mock() + setupChannelManager() + + ldkModule.nodeStateDump(promise) + + verify(promise).resolve(check { + assertTrue { it.startsWith("********NODE STATE********") } + }) + } + + // MARK: Backups + + // TODO: Extract BackupClient as constructor param to enhance testability + + @Test + fun test_backupSetup() { + val promise = mock() + + ldkModule.backupSetup(randomSeed, regtest, backupServer, backupServerPubKey, promise) + + verify(promise).resolve(LdkCallbackResponses.backup_client_setup_success.name) + } + + @Ignore("No http in tests") + @Test + fun test_restoreFromRemoteBackup() { + backupSetup() + + ldkModule.restoreFromRemoteBackup(false, _promise) + } + + @Ignore("No http in tests") + @Test + fun test_backupSelfCheck() { + backupSetup() + + ldkModule.backupSelfCheck(_promise) + } + + @Ignore("No http in tests") + @Test + fun test_backupListFiles() { + backupSetup() + + ldkModule.backupListFiles(_promise) + } + + @Ignore("No http in tests") + @Test + fun test_backupFile() { + backupSetup() + + ldkModule.backupFile("file", "content", _promise) + } + + @Ignore("No http in tests") + @Test + fun test_fetchBackupFile() { + backupSetup() + + ldkModule.fetchBackupFile("file", _promise) + } + + // MARK: Helpers + + private fun initStorage() { + ldkModule.setAccountStoragePath(accountStoragePath, _promise) + ldkModule.setLogFilePath(logsPath, _promise) + } + + private fun initKeysManager() { + val destScriptPubKey = "03a6406c95e3df5300a7bf7fbd86352fb39db94a52ffae2b12feafe0b3f0aab51c" + val witnessProgram = "0b6bd267533b7b8883e8690ad9b951d8f0642c23" + val witnessProgramVer = 1.0 + + ldkModule.initKeysManager( + randomSeed, + destScriptPubKey, + witnessProgram, + witnessProgramVer, + _promise + ) + } + + private fun setupChannelManager() { + initStorage() + initKeysManager() + initUserConfig() + initNetworkGraph() + ldkModule.initChannelManager(regtest, blockHash, blockHeight, _promise) + } + + private fun initUserConfig() { + val userConfig = mock() + + ldkModule.initUserConfig(userConfig, _promise) + } + + private fun initNetworkGraph(promise: Promise = _promise) { + ldkModule.initNetworkGraph( + network = regtest, + rapidGossipSyncUrl = "", + skipHoursThreshold = 3.0, + promise = promise + ) + } + + private fun backupSetup() { + ldkModule.backupSetup(randomSeed, regtest, backupServer, backupServerPubKey, _promise) + } +} diff --git a/lib/android/src/test/java/com/reactnativeldk/testutils/ShadowArguments.kt b/lib/android/src/test/java/com/reactnativeldk/testutils/ShadowArguments.kt new file mode 100644 index 00000000..bb71127d --- /dev/null +++ b/lib/android/src/test/java/com/reactnativeldk/testutils/ShadowArguments.kt @@ -0,0 +1,17 @@ +package com.reactnativeldk.testutils + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(Arguments::class) +class ShadowArguments { + companion object { + @JvmStatic @Implementation fun createMap(): WritableMap = JavaOnlyMap() + @JvmStatic @Implementation fun createArray(): WritableArray = JavaOnlyArray() + } +}