diff --git a/.github/workflows/unit-tests-android.yml b/.github/workflows/unit-tests-android.yml new file mode 100644 index 00000000..b7e5dc84 --- /dev/null +++ b/.github/workflows/unit-tests-android.yml @@ -0,0 +1,39 @@ +name: unit-tests-android + +on: + pull_request: + branches: + - 'master' + push: + branches: + - 'master' + +jobs: + tests-android: + name: Android Unit Tests + runs-on: macos-12 + + steps: + - uses: actions/checkout@v3 + + - name: Use Node 18 + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Install node_modules + working-directory: example + run: yarn install && yarn rn-setup + + - name: Use JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'zulu' + + - name: Use Android SDK + uses: android-actions/setup-android@v2 + + - name: Run Android unit tests + working-directory: lib/android + run: ./gradlew testDebugUnitTest --stacktrace --no-daemon 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..a8fa0ca5 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' + classpath("com.android.tools.build:gradle:7.1.3") // 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] @@ -125,7 +128,15 @@ def kotlin_version = getExtOrDefault('kotlinVersion') dependencies { // noinspection GradleDynamicVersion api 'com.facebook.react:react-native:+' - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation(platform("org.jetbrains.kotlin:kotlin-bom:$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.properties b/lib/android/gradle.properties index 01c7ed4b..687b05f5 100644 --- a/lib/android/gradle.properties +++ b/lib/android/gradle.properties @@ -1,3 +1,4 @@ -Ldk_kotlinVersion=1.6.10 +Ldk_kotlinVersion=1.8.0 Ldk_compileSdkVersion=29 Ldk_targetSdkVersion=29 +android.useAndroidX=true \ No newline at end of file diff --git a/lib/android/gradle/wrapper/gradle-wrapper.jar b/lib/android/gradle/wrapper/gradle-wrapper.jar index e708b1c0..7454180f 100644 Binary files a/lib/android/gradle/wrapper/gradle-wrapper.jar and b/lib/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lib/android/gradle/wrapper/gradle-wrapper.properties b/lib/android/gradle/wrapper/gradle-wrapper.properties index da9702f9..2e6e5897 100644 --- a/lib/android/gradle/wrapper/gradle-wrapper.properties +++ b/lib/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ 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-7.3.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/lib/android/gradlew b/lib/android/gradlew index 4f906e0c..1b6c7873 100755 --- a/lib/android/gradlew +++ b/lib/android/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/lib/android/libs/ldk-java.jar b/lib/android/libs/ldk-java.jar new file mode 100644 index 00000000..bc9bbefe 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 ffd4df7e..412891ce 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) } } @@ -398,9 +398,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 8506215f..2d76fac9 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.* @@ -113,6 +115,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, @@ -143,9 +146,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() } @@ -185,7 +186,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod channelStoragePath = "" } - //Startup methods + //MARK: Startup methods @ReactMethod fun setAccountStoragePath(storagePath: String, promise: Promise) { @@ -206,7 +207,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 @@ -214,8 +215,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)) @@ -261,7 +262,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) } @@ -270,40 +271,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) { @@ -355,7 +322,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() } @@ -385,26 +352,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()) { @@ -435,24 +435,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, @@ -465,16 +463,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!!, @@ -493,9 +491,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 @@ -505,7 +504,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) @@ -528,15 +544,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, @@ -616,13 +632,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() ) ) } @@ -679,7 +694,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) } @@ -718,7 +738,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()) ) } @@ -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) @@ -1122,7 +1145,7 @@ class LdkModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod val output = TxOut(outputValue.toLong(), outputScriptPubKey.hexa()) val outpoint = OutPoint.of(outpointTxId.hexa().reversedArray(), outpointIndex.toInt().toShort()) - val descriptor = SpendableOutputDescriptor.static_output(outpoint, output, byteArrayOf()) + val descriptor = SpendableOutputDescriptor.static_output(outpoint, output, ByteArray(32)) val ldkDescriptors: MutableList = arrayListOf() ldkDescriptors.add(descriptor) @@ -1245,7 +1268,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 { @@ -1280,7 +1303,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() @@ -1439,6 +1462,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..cb44082a --- /dev/null +++ b/lib/android/src/test/java/com/reactnativeldk/LdkModuleTest.kt @@ -0,0 +1,758 @@ +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.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.assertTrue + +private const val FS_ROOT = "build/test-files" + +private val INVOICE_EXPIRED = + "lnbcrt120n1pjlrxz9pp599l2jlsrt4qeczdyh90rzhfsfrkxhu6dged8yhfyhw0kt3sfzsyqdqdw4hxjaz5v4ehgcqzzsxqyz5vqsp54ehunpcenrejgq4tfwr8a4s5tlyrchhk27e70a0vagwr60n8mwhq9qyyssq5t6dqjwu4r6rnjfz7uk9mx0p2h2rxlr00fqyzjtudglay6lzfqp370ml6y8hnwfz8eamhdt4nu7s6jwy64gd9gtl9t8zz5l67cq6j6qqkgmr5v" + +@Config( + manifest = Config.NONE, + shadows = [ShadowArguments::class], +) +@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() { + _promise = mock() + ldkModule = LdkModule(reactContext) + } + + // 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, + 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 + val promise = mock() + setupChannelManager() + + ldkModule.pay(INVOICE_EXPIRED, 0.0, 2.5, promise) + + verify(promise).reject( + eq(LdkErrors.invoice_payment_fail_payment_expired.name), + eq(LdkErrors.invoice_payment_fail_payment_expired.name), + ) + } + + @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() + } +}