diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dccf06ae..9fa4e2cb 100755 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ image: docker:latest variables: + SHARED_PATH: /builds/shared/$CI_PROJECT_PATH GOOGLE_TAG: eu.gcr.io/papers-kubernetes/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME:$CI_COMMIT_SHA GOOGLE_TAG_ANDROID_CURRENT: eu.gcr.io/papers-kubernetes/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME:android-$CI_COMMIT_SHA GOOGLE_TAG_ELECTRON_LINUX_CURRENT: eu.gcr.io/papers-kubernetes/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME:electron-linux-$CI_COMMIT_SHA @@ -11,6 +12,8 @@ stages: - test - qa - native_build + - native_test + - publish build_ionic: stage: build @@ -49,12 +52,29 @@ build_android: - docker run --name $LOCAL_ANDROID $GOOGLE_TAG_ANDROID_CURRENT echo "container ran." - docker cp $LOCAL_ANDROID:/app/android-release-unsigned.apk airgap-vault-release-unsigned-$CI_PIPELINE_ID.apk - docker cp $LOCAL_ANDROID:/app/android-debug.apk airgap-vault-debug-$CI_PIPELINE_ID.apk + - docker cp $LOCAL_ANDROID:/app/android-appium.apk airgap-vault-appium-$CI_PIPELINE_ID.apk after_script: - docker rm -f $LOCAL_ANDROID || true artifacts: paths: - airgap-vault-release-unsigned-$CI_PIPELINE_ID.apk - airgap-vault-debug-$CI_PIPELINE_ID.apk + - airgap-vault-appium-$CI_PIPELINE_ID.apk + +test_android: + stage: native_test + when: manual + tags: + - android-ui + before_script: + - echo "$APPIUM_CONFIG_JSON" > ./uitest/config.json + script: + - docker cp airgap-vault-appium-$CI_PIPELINE_ID.apk appium:/home/airgap-signed.apk + - docker create --network="host" -w /usr/src/uitest --name maven-runner-$CI_PIPELINE_ID maven:3.3-jdk-8 mvn test + - docker cp uitest maven-runner-$CI_PIPELINE_ID:/usr/src/ + - docker start -a maven-runner-$CI_PIPELINE_ID + after_script: + - docker rm -f maven-runner-$CI_PIPELINE_ID || true qa: stage: qa @@ -85,7 +105,6 @@ build_ios: when: manual before_script: - echo "$IOS_BUILD_JSON" > build.json - - echo "$IOS_EXPORT_OPTIONS" > exportOptions.plist script: - export DEVELOPER_DIR=$XCODE_PATH - nvm use 14 @@ -96,12 +115,21 @@ build_ios: - npx ionic info - npx ionic build --prod - npx cap sync ios - - xcodebuild -workspace ios/App/App.xcworkspace -scheme "App" -destination generic/platform=iOS -configuration Release archive -archivePath ios/App.xcarchive MARKETING_VERSION=$VERSION CURRENT_PROJECT_VERSION=$CI_PIPELINE_ID -allowProvisioningUpdates - - xcodebuild -exportArchive -archivePath ios/App.xcarchive -exportOptionsPlist exportOptions.plist -exportPath ios/ -allowProvisioningUpdates - - xcrun altool --upload-app -f ios/App.ipa -u $IOS_USERNAME -p $IOS_PASSWORD + - xcodebuild -workspace ios/App/App.xcworkspace -scheme "App" -destination generic/platform=iOS -configuration Release archive -archivePath ios/airgap-vault-$VERSION-$CI_PIPELINE_ID.xcarchive MARKETING_VERSION=$VERSION CURRENT_PROJECT_VERSION=$CI_PIPELINE_ID -allowProvisioningUpdates artifacts: paths: - - ios/App.ipa - - ios/App.xcarchive + - ios/airgap-vault-$VERSION-$CI_PIPELINE_ID.xcarchive + tags: + - ios + +publish_ios: + stage: publish + when: manual + before_script: + - echo "$IOS_EXPORT_OPTIONS" > exportOptions.plist + script: + - export DEVELOPER_DIR=$XCODE_PATH + - xcodebuild -exportArchive -archivePath ios/airgap-vault-$VERSION-$CI_PIPELINE_ID.xcarchive -exportOptionsPlist exportOptions.plist -exportPath ios/ -allowProvisioningUpdates + - xcrun altool --upload-app -f ios/App.ipa -u $IOS_USERNAME -p $IOS_PASSWORD tags: - ios diff --git a/android/.gitignore b/android/.gitignore index 64a88fbc..bf0c408c 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -89,3 +89,5 @@ capacitor-cordova-android-plugins # Copied web assets app/src/main/assets/public + +.cxx diff --git a/android/app/build.gradle b/android/app/build.gradle index 859f372c..1b04b4a2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,11 +15,28 @@ android { testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } buildTypes { + debug { + buildConfigField "boolean", "APPIUM", "false" + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + buildConfigField "boolean", "APPIUM", "false" + } + appium { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + buildConfigField "boolean", "APPIUM", "true" + matchingFallbacks = ['release'] } } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } lintOptions { abortOnError false } @@ -48,6 +65,9 @@ dependencies { implementation 'com.google.android.material:material:1.2.0' implementation 'com.scottyab:rootbeer-lib:0.0.7' + + def saplingVersion = "0.0.6" + implementation "com.github.airgap-it:airgap-sapling:$saplingVersion" } diff --git a/android/app/src/main/java/it/airgap/vault/MainActivity.java b/android/app/src/main/java/it/airgap/vault/MainActivity.java index eaffc3da..c17c0e1b 100644 --- a/android/app/src/main/java/it/airgap/vault/MainActivity.java +++ b/android/app/src/main/java/it/airgap/vault/MainActivity.java @@ -9,6 +9,7 @@ import it.airgap.vault.plugin.appinfo.AppInfo; import it.airgap.vault.plugin.camerapreview.CameraPreview; +import it.airgap.vault.plugin.saplingnative.SaplingNative; import it.airgap.vault.plugin.securityutils.SecurityUtils; public class MainActivity extends BridgeActivity { @@ -23,6 +24,7 @@ public void onCreate(Bundle savedInstanceState) { add(CameraPreview.class); add(AppInfo.class); add(SecurityUtils.class); + add(SaplingNative.class); }}); } } diff --git a/android/app/src/main/java/it/airgap/vault/plugin/saplingnative/SaplingNative.kt b/android/app/src/main/java/it/airgap/vault/plugin/saplingnative/SaplingNative.kt new file mode 100644 index 00000000..9ea90d12 --- /dev/null +++ b/android/app/src/main/java/it/airgap/vault/plugin/saplingnative/SaplingNative.kt @@ -0,0 +1,174 @@ +package it.airgap.vault.plugin.saplingnative + +import com.getcapacitor.NativePlugin +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import it.airgap.sapling.Sapling +import it.airgap.vault.util.* +import kotlin.concurrent.thread + +@NativePlugin +class SaplingNative : Plugin() { + private val sapling: Sapling by lazy { Sapling() } + + @PluginMethod + fun isSupported(call: PluginCall) { + call.resolveWithData(Key.IS_SUPPORTED to true) + } + + @PluginMethod + fun initParameters(call: PluginCall) { + with(call) { + thread { + tryResolveCatchReject { + val spendParameters = readFromAssets("public/assets/sapling/sapling-spend.params") + val outputParameters = readFromAssets("public/assets/sapling/sapling-output.params") + + sapling.initParameters(spendParameters, outputParameters) + } + } + } + } + + @PluginMethod + fun initProvingContext(call: PluginCall) { + call.resolveWithData(Key.CONTEXT to sapling.initProvingContext().toString()) + } + + @PluginMethod + fun dropProvingContext(call: PluginCall) { + with(call) { + assertReceived(Param.CONTEXT) + sapling.dropProvingContext(context) + resolve() + } + } + + @PluginMethod + fun prepareSpendDescription(call: PluginCall) { + with(call) { + assertReceived( + Param.CONTEXT, + Param.SPENDING_KEY, + Param.ADDRESS, + Param.RCM, + Param.AR, + Param.VALUE, + Param.ROOT, + Param.MERKLE_PATH + ) + + tryResolveWithDataCatchReject { + val spendDescription = sapling.prepareSpendDescription( + context, + spendingKey, + address, + rcm, + ar, + value, + root, + merklePath + ) + + listOf(Key.SPEND_DESCRIPTION to spendDescription.asHexString()) + } + } + } + + @PluginMethod + fun preparePartialOutputDescription(call: PluginCall) { + with(call) { + assertReceived( + Param.CONTEXT, + Param.ADDRESS, + Param.RCM, + Param.ESK, + Param.VALUE + ) + + tryResolveWithDataCatchReject { + val outputDescription = sapling.preparePartialOutputDescription( + context, + address, + rcm, + esk, + value + ) + + listOf(Key.OUTPUT_DESCRIPTION to outputDescription.asHexString()) + } + } + } + + @PluginMethod + fun createBindingSignature(call: PluginCall) { + with(call) { + assertReceived(Param.CONTEXT, Param.BALANCE, Param.SIGHASH) + + tryResolveWithDataCatchReject { + val bindingSignature = sapling.createBindingSignature(context, balance, sighash) + + listOf(Key.BINDING_SIGNATURE to bindingSignature.asHexString()) + } + } + } + + private val PluginCall.context: Long + get() = getString(Param.CONTEXT).toLong() + + private val PluginCall.spendingKey: ByteArray + get() = getString(Param.SPENDING_KEY).asByteArray() + + private val PluginCall.address: ByteArray + get() = getString(Param.ADDRESS).asByteArray() + + private val PluginCall.rcm: ByteArray + get() = getString(Param.RCM).asByteArray() + + private val PluginCall.ar: ByteArray + get() = getString(Param.AR).asByteArray() + + private val PluginCall.esk: ByteArray + get() = getString(Param.ESK).asByteArray() + + private val PluginCall.value: Long + get() = getString(Param.VALUE).toLong() + + private val PluginCall.root: ByteArray + get() = getString(Param.ROOT).asByteArray() + + private val PluginCall.merklePath: ByteArray + get() = getString(Param.MERKLE_PATH).asByteArray() + + private val PluginCall.balance: Long + get() = getString(Param.BALANCE).toLong() + + private val PluginCall.sighash: ByteArray + get() = getString(Param.SIGHASH).asByteArray() + + private object Param { + const val CONTEXT = "context" + + const val SPENDING_KEY = "spendingKey" + const val ADDRESS = "address" + const val RCM = "rcm" + const val AR = "ar" + const val ESK = "esk" + const val VALUE = "value" + const val ROOT = "root" + const val MERKLE_PATH = "merklePath" + + const val BALANCE = "balance" + const val SIGHASH = "sighash" + } + + private object Key { + const val IS_SUPPORTED = "isSupported" + + const val CONTEXT = "context" + const val SPEND_DESCRIPTION = "spendDescription" + const val OUTPUT_DESCRIPTION = "outputDescription" + const val BINDING_SIGNATURE = "bindingSignature" + } +} diff --git a/android/app/src/main/java/it/airgap/vault/plugin/securityutils/SecurityUtils.kt b/android/app/src/main/java/it/airgap/vault/plugin/securityutils/SecurityUtils.kt index 0b9845ce..a1286165 100644 --- a/android/app/src/main/java/it/airgap/vault/plugin/securityutils/SecurityUtils.kt +++ b/android/app/src/main/java/it/airgap/vault/plugin/securityutils/SecurityUtils.kt @@ -58,12 +58,14 @@ class SecurityUtils : Plugin() { @Synchronized get @Synchronized set - private val integrityAssessment: Boolean + private val isDebuggable: Boolean get() { - val isRooted = RootBeer(context).isRootedWithoutBusyBoxCheck - val nonDebuggable = BuildConfig.DEBUG || (context.applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0 + return BuildConfig.DEBUG || (context.applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + } - return !isRooted && nonDebuggable + private val integrityAssessment: Boolean + get() { + return !RootBeer(context).isRootedWithoutBusyBoxCheck || isDebuggable || BuildConfig.APPIUM } /* @@ -321,16 +323,20 @@ class SecurityUtils : Plugin() { @PluginMethod fun setWindowSecureFlag(call: PluginCall) { - with (activity) { - runOnUiThread { window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) } + if(!BuildConfig.APPIUM){ + with (activity) { + runOnUiThread { window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) } + } } call.resolve() } @PluginMethod fun clearWindowSecureFlag(call: PluginCall) { - with (activity) { - runOnUiThread { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + if(!BuildConfig.APPIUM){ + with (activity) { + runOnUiThread { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + } } call.resolve() } @@ -466,4 +472,4 @@ class SecurityUtils : Plugin() { private const val PREFERENCES_KEY_AUTOMATIC_AUTHENTICATION = "autoauth" private const val MAX_AUTH_TRIES = 3 } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/it/airgap/vault/util/Hex.kt b/android/app/src/main/java/it/airgap/vault/util/Hex.kt new file mode 100644 index 00000000..4e6874d8 --- /dev/null +++ b/android/app/src/main/java/it/airgap/vault/util/Hex.kt @@ -0,0 +1,8 @@ +package it.airgap.vault.util + +fun String.isHex(): Boolean = matches(Regex("^(0x)?([0-9a-fA-F]{2})+$")) + +fun String.asByteArray(): ByteArray = + if (isHex()) chunked(2).map { it.toInt(16).toByte() }.toByteArray() else toByteArray() + +fun ByteArray.asHexString(): String = joinToString(separator = "") { "%02x".format(it) } \ No newline at end of file diff --git a/android/app/src/main/java/it/airgap/vault/util/PluginExtensions.kt b/android/app/src/main/java/it/airgap/vault/util/PluginExtensions.kt index e495aa6b..d445c506 100644 --- a/android/app/src/main/java/it/airgap/vault/util/PluginExtensions.kt +++ b/android/app/src/main/java/it/airgap/vault/util/PluginExtensions.kt @@ -21,6 +21,9 @@ inline fun Plugin.requiresPermissions(code: Int, vararg permissions: String, blo } } +fun Plugin.readFromAssets(path: String): ByteArray = + bridge.activity.assets.open(path).use { it.readBytes() } + fun Plugin.logDebug(message: String) { Log.d(this::class.java.simpleName, message) } @@ -36,10 +39,31 @@ fun Plugin.freeCallIfSaved() { */ fun PluginCall.resolveWithData(vararg keyValuePairs: Pair) { - val data = JSObject().apply { - keyValuePairs.forEach { put(it.first, it.second) } + if (keyValuePairs.isEmpty()) { + resolve() + } else { + val data = JSObject().apply { + keyValuePairs.forEach { put(it.first, it.second) } + } + resolve(data) + } +} + +fun PluginCall.tryResolveCatchReject(block: () -> Unit) { + try { + block() + resolve() + } catch (e: Throwable) { + reject(e.message) + } +} + +fun PluginCall.tryResolveWithDataCatchReject(block: () -> List>) { + try { + resolveWithData(*block().toTypedArray()) + } catch (e: Throwable) { + reject(e.message) } - resolve(data) } fun PluginCall.assertReceived(vararg params: String, acceptEmpty: Boolean = false) { diff --git a/android/build.gradle b/android/build.gradle index d20354e4..084a8546 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -23,6 +23,7 @@ allprojects { repositories { google() jcenter() + maven { url 'https://jitpack.io' } } } diff --git a/angular.json b/angular.json index a4b85eca..b563e291 100644 --- a/angular.json +++ b/angular.json @@ -70,8 +70,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "2mb", - "maximumError": "6mb" + "maximumWarning": "55mb", + "maximumError": "60mb" }, { "type": "anyComponentStyle", diff --git a/build/android/Dockerfile b/build/android/Dockerfile index 64ab92d4..9f56e04b 100644 --- a/build/android/Dockerfile +++ b/build/android/Dockerfile @@ -1,79 +1,88 @@ -FROM beevelop/ionic:v5.2.3 - -RUN apt-get update -y && apt-get install -y \ - bzip2 \ - build-essential \ - pkg-config \ - libjpeg-dev \ - libcairo2-dev - -# create app directory -RUN mkdir /app -WORKDIR /app - -# using npm 6.5.0 to fix installing certain cordova/ionic plugins -RUN npm install -g npm@6.5.0 ionic@5.4.0 @capacitor/core@2.4.0 @capacitor/cli@2.4.0 -RUN npm cache clean -f -RUN npm install -g n -RUN n 10.14.1 - -# Install app dependencies, using wildcard if package-lock exists -COPY package.json /app/package.json -COPY package-lock.json /app/package-lock.json - -# install dependencies -RUN npm ci - -# copy capacitor configs and ionic configs -COPY capacitor.config.json /app/capacitor.config.json -COPY ionic.config.json /app/ionic.config.json - -RUN mkdir www - -# run ionic android build -RUN ionic info - -# Bundle app source -COPY . /app - -# post-install hook, to be safe if it got cached -RUN node config/patch_crypto.js - -# set version code -ARG BUILD_NR -RUN sed -i -e "s/versionCode 1/versionCode $BUILD_NR/g" /app/android/app/build.gradle - -# disable pure getters due to https://github.com/angular/angular-cli/issues/11439 -RUN npm run disable-pure-getters - -# configure mangle (keep_fnames) for bitcoinjs https://github.com/bitcoinjs/bitcoinjs-lib/issues/959 -RUN npm run configure-mangle - -# remove unused cordova-diagnostic-plugin features -RUN npm run apply-diagnostic-modules - -# jetify dependencies -RUN npx jetifier - -# build ionic -RUN ionic build --prod - -# copy ionic build -RUN cap sync android - -# accept licenses -RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update - -# clean project -RUN /app/android/gradlew --project-dir /app/android clean - -# build apk -RUN /app/android/gradlew --project-dir /app/android build - -# copy release-apk -RUN cp /app/android/app/build/outputs/apk/release/app-release-unsigned.apk android-release-unsigned.apk - -RUN cp android-release-unsigned.apk android-debug.apk - -# sign using debug key -RUN jarsigner -verbose -keystore ./build/android/debug.keystore -storepass android -keypass android android-debug.apk androiddebugkey +FROM beevelop/ionic:v5.2.3 + +RUN apt-get update -y && apt-get install -y \ + bzip2 \ + build-essential \ + pkg-config \ + libjpeg-dev \ + libcairo2-dev + +# create app directory +RUN mkdir /app +WORKDIR /app + +# using npm 6.5.0 to fix installing certain cordova/ionic plugins +RUN npm install -g npm@6.5.0 ionic@5.4.0 @capacitor/core@2.4.0 @capacitor/cli@2.4.0 +RUN npm cache clean -f +RUN npm install -g n +RUN n 10.14.1 + +# Install app dependencies, using wildcard if package-lock exists +COPY package.json /app/package.json +COPY package-lock.json /app/package-lock.json + +# install dependencies +RUN npm ci + +# copy capacitor configs and ionic configs +COPY capacitor.config.json /app/capacitor.config.json +COPY ionic.config.json /app/ionic.config.json + +RUN mkdir www + +# run ionic android build +RUN ionic info + +# Bundle app source +COPY . /app + +# post-install hook, to be safe if it got cached +RUN node config/patch_crypto.js + +# set version code +ARG BUILD_NR +RUN sed -i -e "s/versionCode 1/versionCode $BUILD_NR/g" /app/android/app/build.gradle + +# disable pure getters due to https://github.com/angular/angular-cli/issues/11439 +RUN npm run disable-pure-getters + +# configure mangle (keep_fnames) for bitcoinjs https://github.com/bitcoinjs/bitcoinjs-lib/issues/959 +RUN npm run configure-mangle + +# remove unused cordova-diagnostic-plugin features +RUN npm run apply-diagnostic-modules + +# jetify dependencies +RUN npx jetifier + +# build ionic +RUN ionic build --prod + +# copy ionic build +RUN cap sync android + +# accept licenses +RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update + +# clean project +RUN /app/android/gradlew --project-dir /app/android clean + +# build apk +RUN /app/android/gradlew --project-dir /app/android build + +# copy release-apk +RUN cp /app/android/app/build/outputs/apk/release/app-release-unsigned.apk android-release-unsigned.apk +# this has nothing to do with debug!!!: +RUN cp android-release-unsigned.apk android-debug.apk + +# copy release-apk +RUN cp /app/android/app/build/outputs/apk/appium/app-appium-unsigned.apk android-appium-unsigned.apk + +RUN cp android-appium-unsigned.apk android-appium.apk + + +# sign using debug key +RUN jarsigner -verbose -keystore ./build/android/debug.keystore -storepass android -keypass android android-debug.apk androiddebugkey + +# sign using debug key +RUN jarsigner -verbose -keystore ./build/android/debug.keystore -storepass android -keypass android android-appium.apk androiddebugkey diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 7f88097d..b2b1a628 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -3,18 +3,22 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 49690A7E25C2BF80004A3586 /* VaultError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49690A7D25C2BF80004A3586 /* VaultError.swift */; }; + 49D642732629D22A0066C013 /* SaplingFFI in Frameworks */ = {isa = PBXBuildFile; productRef = 49D642722629D22A0066C013 /* SaplingFFI */; }; + 49D642752629D22A0066C013 /* Sapling in Frameworks */ = {isa = PBXBuildFile; productRef = 49D642742629D22A0066C013 /* Sapling */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 885F55E825EE224200D85A88 /* SaplingNative.m in Sources */ = {isa = PBXBuildFile; fileRef = 885F55E725EE224200D85A88 /* SaplingNative.m */; }; + 885F55EB25EE226300D85A88 /* SaplingNative.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885F55EA25EE226300D85A88 /* SaplingNative.swift */; }; 88AE2EE823D5C7A500428560 /* SecurityUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AE2EE723D5C7A500428560 /* SecurityUtils.swift */; }; 88AE2EED23D5CB3200428560 /* PluginCall+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AE2EEC23D5CB3200428560 /* PluginCall+Additions.swift */; }; 88AE2EEF23D5D7B300428560 /* SecurityUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 88AE2EEE23D5D7B300428560 /* SecurityUtils.m */; }; @@ -42,6 +46,8 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = SOURCE_ROOT; }; + 885F55E725EE224200D85A88 /* SaplingNative.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SaplingNative.m; sourceTree = ""; }; + 885F55EA25EE226300D85A88 /* SaplingNative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaplingNative.swift; sourceTree = ""; }; 88AE2EE723D5C7A500428560 /* SecurityUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityUtils.swift; sourceTree = ""; }; 88AE2EEC23D5CB3200428560 /* PluginCall+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginCall+Additions.swift"; sourceTree = ""; }; 88AE2EEE23D5D7B300428560 /* SecurityUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SecurityUtils.m; sourceTree = ""; }; @@ -66,7 +72,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 49D642752629D22A0066C013 /* Sapling in Frameworks */, A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + 49D642732629D22A0066C013 /* SaplingFFI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -102,6 +110,7 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( + 885F55E525EE220A00D85A88 /* SaplingNative */, 88AE2EEA23D5CB1300428560 /* Helpers */, 88AE2EDC23D5C40800428560 /* SecurityUtils */, 88F9F15023D069AA008351D0 /* Camera */, @@ -128,6 +137,15 @@ name = Pods; sourceTree = ""; }; + 885F55E525EE220A00D85A88 /* SaplingNative */ = { + isa = PBXGroup; + children = ( + 885F55E725EE224200D85A88 /* SaplingNative.m */, + 885F55EA25EE226300D85A88 /* SaplingNative.swift */, + ); + path = SaplingNative; + sourceTree = ""; + }; 88AE2EDC23D5C40800428560 /* SecurityUtils */ = { isa = PBXGroup; children = ( @@ -196,6 +214,10 @@ dependencies = ( ); name = App; + packageProductDependencies = ( + 49D642722629D22A0066C013 /* SaplingFFI */, + 49D642742629D22A0066C013 /* Sapling */, + ); productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; @@ -225,6 +247,9 @@ Base, ); mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + 49D642712629D22A0066C013 /* XCRemoteSwiftPackageReference "airgap-sapling" */, + ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -293,6 +318,7 @@ files = ( 88AE2EF323D5DC3900428560 /* Keychain.swift in Sources */, 88F9F14723D059AE008351D0 /* AppInfo.m in Sources */, + 885F55E825EE224200D85A88 /* SaplingNative.m in Sources */, 49690A7E25C2BF80004A3586 /* VaultError.swift in Sources */, 88AE2EF923D5DC8300428560 /* SecureStorage.swift in Sources */, 88F9F15623D06A76008351D0 /* CameraPreview.m in Sources */, @@ -301,6 +327,7 @@ 88AE2EEF23D5D7B300428560 /* SecurityUtils.m in Sources */, 88F9F15423D06A00008351D0 /* CameraPreview.swift in Sources */, 88AE2EF123D5DC2400428560 /* DeviceIntegrity.swift in Sources */, + 885F55EB25EE226300D85A88 /* SaplingNative.swift in Sources */, 88AE2EED23D5CB3200428560 /* PluginCall+Additions.swift in Sources */, 88F9F14423D058DD008351D0 /* AppInfo.swift in Sources */, 88AE2EE823D5C7A500428560 /* SecurityUtils.swift in Sources */, @@ -437,7 +464,8 @@ IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; @@ -453,7 +481,10 @@ DEVELOPMENT_TEAM = 7VLXNQ52UC; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 3.1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = it.airgap.vault; @@ -477,7 +508,10 @@ DEVELOPMENT_TEAM = 7VLXNQ52UC; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 3.1.0; PRODUCT_BUNDLE_IDENTIFIER = it.airgap.vault; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -510,6 +544,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 49D642712629D22A0066C013 /* XCRemoteSwiftPackageReference "airgap-sapling" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airgap-it/airgap-sapling"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.6; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 49D642722629D22A0066C013 /* SaplingFFI */ = { + isa = XCSwiftPackageProductDependency; + package = 49D642712629D22A0066C013 /* XCRemoteSwiftPackageReference "airgap-sapling" */; + productName = SaplingFFI; + }; + 49D642742629D22A0066C013 /* Sapling */ = { + isa = XCSwiftPackageProductDependency; + package = 49D642712629D22A0066C013 /* XCRemoteSwiftPackageReference "airgap-sapling" */; + productName = Sapling; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; } diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..cc5a0400 --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "AirGapSapling", + "repositoryURL": "https://github.com/airgap-it/airgap-sapling", + "state": { + "branch": null, + "revision": "c6606fce5d84cea678414681b13ef5efb3629a7e", + "version": "0.0.6" + } + } + ] + }, + "version": 1 +} diff --git a/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme index 95183528..0727d124 100644 --- a/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -74,5 +74,23 @@ + + + + + + + + + + diff --git a/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..cc5a0400 --- /dev/null +++ b/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "AirGapSapling", + "repositoryURL": "https://github.com/airgap-it/airgap-sapling", + "state": { + "branch": null, + "revision": "c6606fce5d84cea678414681b13ef5efb3629a7e", + "version": "0.0.6" + } + } + ] + }, + "version": 1 +} diff --git a/ios/App/App/SaplingNative/SaplingNative.m b/ios/App/App/SaplingNative/SaplingNative.m new file mode 100644 index 00000000..fd744ce2 --- /dev/null +++ b/ios/App/App/SaplingNative/SaplingNative.m @@ -0,0 +1,22 @@ +// +// SaplingNative.m +// App +// +// Created by Julia Samol on 02.03.21. +// + +#import +#import + +CAP_PLUGIN(SaplingNative, "SaplingNative", + CAP_PLUGIN_METHOD(isSupported, CAPPluginReturnPromise); + + CAP_PLUGIN_METHOD(initParameters, CAPPluginReturnPromise); + + CAP_PLUGIN_METHOD(initProvingContext, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(dropProvingContext, CAPPluginReturnPromise); + + CAP_PLUGIN_METHOD(prepareSpendDescription, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(preparePartialOutputDescription, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(createBindingSignature, CAPPluginReturnPromise); +) diff --git a/ios/App/App/SaplingNative/SaplingNative.swift b/ios/App/App/SaplingNative/SaplingNative.swift new file mode 100644 index 00000000..7bdf050f --- /dev/null +++ b/ios/App/App/SaplingNative/SaplingNative.swift @@ -0,0 +1,281 @@ +// +// SaplingNative.swift +// App +// +// Created by Julia Samol on 02.03.21. +// + +import Foundation +import Capacitor + +#if (arch(x86_64) || arch(arm64)) +import Sapling +#endif + +@objc(SaplingNative) +public class SaplingNative: CAPPlugin { + + @objc func isSupported(_ call: CAPPluginCall) { + #if (arch(x86_64) || arch(arm64)) + call.resolve([Key.IS_SUPPORTED: true]) + #else + call.resolve([Key.IS_SUPPORTED: false]) + #endif + } + + #if (arch(x86_64) || arch(arm64)) + private lazy var sapling = Sapling() + + @objc func initParameters(_ call: CAPPluginCall) { + do { + let publicURL = Bundle.main.url(forResource: "public", withExtension: nil) + guard + let spendParamsPath = publicURL?.appendingPathComponent("assets/sapling/sapling-spend.params"), + let spendParams = FileManager.default.contents(atPath: spendParamsPath.path), + let outputParamsPath = publicURL?.appendingPathComponent("assets/sapling/sapling-output.params"), + let outputParams = FileManager.default.contents(atPath: outputParamsPath.path) + else { + throw Error.fileNotFound + } + + try sapling.initParameters(spend: [UInt8](spendParams), output: [UInt8](outputParams)) + call.resolve() + } catch { + call.reject("Error: \(error)") + } + } + + @objc func initProvingContext(_ call: CAPPluginCall) { + do { + let context = try sapling.initProvingContext() + + call.resolve([Key.CONTEXT: String(Int(bitPattern: context))]) + } catch { + call.reject("Error: \(error)") + } + } + + @objc func dropProvingContext(_ call: CAPPluginCall) { + call.assertReceived(forMethod: "dropProvingContext", requiredParams: Param.CONTEXT) + + do { + guard let context = call.context else { + throw Error.invalidData + } + sapling.dropProvingContext(context) + + call.resolve() + } catch { + call.reject("Error: \(error)") + } + } + + @objc func prepareSpendDescription(_ call: CAPPluginCall) { + call.assertReceived( + forMethod: "prepareSpendDescription", + requiredParams: Param.CONTEXT, + Param.SPENDING_KEY, + Param.ADDRESS, + Param.RCM, + Param.AR, + Param.VALUE, + Param.ROOT, + Param.MERKLE_PATH + ) + + do { + guard + let context = call.context, + let spendingKey = call.spendingKey, + let address = call.address, + let rcm = call.rcm, + let ar = call.ar, + let value = call.value, + let root = call.root, + let merklePath = call.merklePath + else { throw Error.invalidData } + + let spendDescription = try sapling.prepareSpendDescription( + with: context, + using: spendingKey, + to: address, + withRcm: rcm, + withAr: ar, + ofValue: value, + withAnchor: root, + at: merklePath + ) + + call.resolve([Key.SPEND_DESCRIPTION: spendDescription.asHexString()]) + } catch { + call.reject("Error: \(error)") + } + } + + @objc func preparePartialOutputDescription(_ call: CAPPluginCall) { + call.assertReceived( + forMethod: "preparePartialOutputDescription", + requiredParams: Param.CONTEXT, + Param.ADDRESS, + Param.RCM, + Param.ESK, + Param.VALUE + ) + + do { + guard + let context = call.context, + let address = call.address, + let rcm = call.rcm, + let esk = call.esk, + let value = call.value + else { throw Error.invalidData } + + let outputDescription = try sapling.preparePartialOutputDescription( + with: context, + to: address, + withRcm: rcm, + withEsk: esk, + ofValue: value + ) + + call.resolve([Key.OUTPUT_DESCRIPTION: outputDescription.asHexString()]) + } catch { + call.reject("Error: \(error)") + } + } + + @objc func createBindingSignature(_ call: CAPPluginCall) { + call.assertReceived(forMethod: "createBindingSignature", requiredParams: Param.CONTEXT, Param.BALANCE, Param.SIGHASH) + + do { + guard + let context = call.context, + let balance = call.balance, + let sighash = call.sighash + else { throw Error.invalidData } + + let bindingSignature = try sapling.createBindingSignature(with: context, balance: balance, sighash: sighash) + + call.resolve([Key.BINDING_SIGNATURE: bindingSignature.asHexString()]) + } catch { + call.reject("Error: \(error)") + } + } + #else + @objc func initParameters(_ call: CAPPluginCall) { + call.reject("Unsupported call") + } + + @objc func initProvingContext(_ call: CAPPluginCall) { + call.reject("Unsupported call") + } + + @objc func dropProvingContext(_ call: CAPPluginCall) { + call.reject("Unsupported call") + } + + @objc func prepareSpendDescription(_ call: CAPPluginCall) { + call.reject("Unsupported call") + } + + @objc func preparePartialOutputDescription(_ call: CAPPluginCall) { + call.reject("Unsupported call") + } + + @objc func prepareBindingSignature(_ call: CAPPluginCall) { + call.reject("Unsupported call") + } + #endif + + struct Param { + static let CONTEXT = "context" + + static let SPENDING_KEY = "spendingKey" + static let ADDRESS = "address" + static let RCM = "rcm" + static let AR = "ar" + static let ESK = "esk" + static let VALUE = "value" + static let ROOT = "root" + static let MERKLE_PATH = "merklePath" + + static let BALANCE = "balance" + static let SIGHASH = "sighash" + } + + struct Key { + static let IS_SUPPORTED = "isSupported" + + static let CONTEXT = "context" + static let SPEND_DESCRIPTION = "spendDescription" + static let OUTPUT_DESCRIPTION = "outputDescription" + static let BINDING_SIGNATURE = "bindingSignature" + } + + enum Error: Swift.Error { + case fileNotFound + case invalidData + } +} + +private extension CAPPluginCall { + var context: UnsafeMutableRawPointer? { + guard let stringValue = getString(SaplingNative.Param.CONTEXT), let intValue = Int(stringValue) else { + return nil + } + + return UnsafeMutableRawPointer(bitPattern: intValue) + } + + var spendingKey: [UInt8]? { return getString(SaplingNative.Param.SPENDING_KEY)?.asBytes() } + var address: [UInt8]? { return getString(SaplingNative.Param.ADDRESS)?.asBytes() } + var rcm: [UInt8]? { return getString(SaplingNative.Param.RCM)?.asBytes() } + var ar: [UInt8]? { return getString(SaplingNative.Param.AR)?.asBytes() } + var esk: [UInt8]? { return getString(SaplingNative.Param.ESK)?.asBytes() } + var value: UInt64? { + guard let stringValue = getString(SaplingNative.Param.VALUE) else { + return nil + } + + return UInt64(stringValue) + } + var root: [UInt8]? { return getString(SaplingNative.Param.ROOT)?.asBytes() } + var merklePath: [UInt8]? { return getString(SaplingNative.Param.MERKLE_PATH)?.asBytes() } + + var balance: Int64? { + guard let stringValue = getString(SaplingNative.Param.BALANCE) else { + return nil + } + + return Int64(stringValue) + } + var sighash: [UInt8]? { return getString(SaplingNative.Param.SIGHASH)?.asBytes() } +} + +extension String { + func asBytes() -> [UInt8]? { + var bytes = [UInt8]() + bytes.reserveCapacity(count / 2) + + for (position, index) in indices.enumerated() { + guard position % 2 == 0 else { + continue + } + let byteRange = index...self.index(after: index) + let byteSlice = self[byteRange] + guard let byte = UInt8(byteSlice, radix: 16) else { + return nil + } + bytes.append(byte) + } + + return bytes + } +} + +extension Array where Element == UInt8 { + func asHexString() -> String { + return map { b in String(format: "%02x", b) }.joined() + } +} diff --git a/package-lock.json b/package-lock.json index d05a7820..3c482c0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,30 +11,59 @@ "dev": true }, "@airgap/angular-core": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@airgap/angular-core/-/angular-core-0.0.8.tgz", - "integrity": "sha512-lOaSeJWyYpKGzC/I5d9pYSYjgMIIl99E25dsH0MFy8QPOW6GA5QQoDQIsCx3KyhKG+8G0rD5uSJBSxqDTSsFtg==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@airgap/angular-core/-/angular-core-0.0.9.tgz", + "integrity": "sha512-aDIPXUX1GKFcmn9/x4ZJJh7+EoAAG75XG+S3U2Y7NyZJnYukypjY3Nt3Lx7G1pCnnV2B1orP+ox/0BnFWVn8tw==", "requires": { "tslib": "^2.0.0" }, "dependencies": { "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", + "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" + } + } + }, + "@airgap/angular-ngrx": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@airgap/angular-ngrx/-/angular-ngrx-0.0.9.tgz", + "integrity": "sha512-RQ7RmPtlhtCRQdscNU1vmkpYw44/FFv6mFxAnA6wW30c8G/oq+mdGdGlA6GqJCZQ2lyHJsyac7gPVmGD6HmifQ==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", + "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" } } }, "@airgap/coinlib-core": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@airgap/coinlib-core/-/coinlib-core-0.10.5.tgz", - "integrity": "sha512-eJ1Y8FDuEYzAcQzdt600XlrqezLGBnXXNyFHZ5sTzC4E3MlWq32jQIJUuI/vtg0yMp50H9XYAAEtWd2D/bE1ng==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@airgap/coinlib-core/-/coinlib-core-0.11.0.tgz", + "integrity": "sha512-bo925ntB0jGanuj9JpwefXtIbZo2+wyLdiShYxNdtLo3hLyuIMd7mOLI13TLTDs2vSTaWGnOcjVGOeE8GPJmFg==", "requires": { + "@airgap/sapling-wasm": "0.0.5", "@polkadot/util": "2.0.1", "@polkadot/wasm-crypto": "0.20.1", + "idna-uts46-hx": "^3.3.1", "libsodium-wrappers": "0.7.6" + }, + "dependencies": { + "@airgap/sapling-wasm": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@airgap/sapling-wasm/-/sapling-wasm-0.0.5.tgz", + "integrity": "sha512-u3dCsqC4WqTlscAU6p5BED+O/tXEDRcIzeOf7OTtz4zoK8wyMX6ZcJImZONhYG6IKu8cSc8eQNwm6fghHL3SxA==" + } } }, + "@airgap/sapling-wasm": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@airgap/sapling-wasm/-/sapling-wasm-0.0.6.tgz", + "integrity": "sha512-hGD1ZMe3aLIJdjRxq6KVLjMkxza9BW2AZxJEWtVOLAwJDvUyb4yw+i2hnjIKve2kO82m6UJ3tJIDGhbZ4gU6eg==" + }, "@angular-devkit/architect": { "version": "0.1000.6", "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1000.6.tgz", @@ -2082,6 +2111,13 @@ "integrity": "sha512-qJcZJtXaXUpwKTMzLc6tGitHJVYQCcSlx2XNQUiKyck47g98Xxo8D0zgHoRiCQvApOqw1iEKzh6xs5PLkmcXqw==", "requires": { "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } } }, "@ionic-native/device-motion": { @@ -2098,6 +2134,13 @@ "integrity": "sha512-TwRLorUXRrJiNMCOM+UGKDmb7akt8NaIJtQ9vscUUO8ym25wEqav63wIJbySNUyeSIRcfK/lJ0cobvs2scVXDw==", "requires": { "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } } }, "@ionic/angular": { @@ -2107,6 +2150,17 @@ "requires": { "@ionic/core": "5.5.2", "tslib": "^1.9.3" + }, + "dependencies": { + "@ionic/core": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.5.2.tgz", + "integrity": "sha512-rOfPj8D5NRWdOYYulNTdKtMAMURfmutDQ3ciA3L7daCooG3MWt2/0siiL6rcZFMxfG7KDxHctuwVwYoC1mPuhg==", + "requires": { + "ionicons": "^5.1.2", + "tslib": "^1.10.0" + } + } } }, "@ionic/angular-toolkit": { @@ -2379,9 +2433,9 @@ } }, "@ionic/core": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.5.2.tgz", - "integrity": "sha512-rOfPj8D5NRWdOYYulNTdKtMAMURfmutDQ3ciA3L7daCooG3MWt2/0siiL6rcZFMxfG7KDxHctuwVwYoC1mPuhg==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.5.4.tgz", + "integrity": "sha512-IjbGN8vh3XuJ2ulo3BMlMflcWlUhvEGEexr29JKFvb+O4bWKP5sC2fkqSrswrIstOmv7axm7CeIi2MNRkwYwVA==", "requires": { "ionicons": "^5.1.2", "tslib": "^1.10.0" @@ -2684,6 +2738,51 @@ "schema-utils": "^2.7.0" } }, + "@ngrx/component-store": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ngrx/component-store/-/component-store-10.1.2.tgz", + "integrity": "sha512-UuI55Ut1mAzGMX/SLHByOeJfC3hD5Z8+qv7aQTV/iHyMdpSdBpj3O92kPbDCSl+jXWGpjFNi45iY7bfOLZNFaQ==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "@ngrx/effects": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-10.1.2.tgz", + "integrity": "sha512-6pX6FEzLlqdbtFVMbCvscsaL6QC/L95e72JKj76Xz+8V77UTlpVsxWyMo7YU9pM4EXNpBGmOpMs2xKjfBfK05Q==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "@ngrx/store": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-10.1.2.tgz", + "integrity": "sha512-FUjN786ch4Qt9WgJ78ef7Yquq3mPCekgcWgZrs4ycZw1f+KdfTHLTk1bGDtO8A8CzOya5yTT7KhxbdVjbOS5ng==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, "@ngtools/webpack": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-11.0.4.tgz", @@ -3876,6 +3975,14 @@ } } }, + "base-x": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.8.tgz", + "integrity": "sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "base64-arraybuffer": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", @@ -3942,12 +4049,31 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "optional": true, "requires": { "file-uri-to-path": "1.0.0" } }, + "bip32": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.6.tgz", + "integrity": "sha512-HpV5OMLLGTjSVblmrtYRfFFKuQB+GArM0+XP8HGWfJ5vxYBqo+DesvJwOdC2WJ3bCkZShGf0QIfoIpeomVzVdA==", + "requires": { + "@types/node": "10.12.18", + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.1.3", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "dependencies": { + "@types/node": { + "version": "10.12.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.18.tgz", + "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==" + } + } + }, "bip39": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/bip39/-/bip39-2.6.0.tgz", @@ -4186,8 +4312,7 @@ "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", - "dev": true + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, "browserify-aes": { "version": "1.2.0", @@ -4292,6 +4417,24 @@ "https-proxy-agent": "^2.2.1" } }, + "bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha1-vhYedsNU9veIrkBx9j806MTwpCo=", + "requires": { + "base-x": "^3.0.2" + } + }, + "bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "requires": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, "buffer": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", @@ -5580,7 +5723,8 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true }, "cosmiconfig": { "version": "5.2.1", @@ -6868,7 +7012,6 @@ "version": "6.5.3", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", - "dev": true, "requires": { "bn.js": "^4.4.0", "brorand": "^1.0.1", @@ -6882,8 +7025,7 @@ "bn.js": { "version": "4.11.9", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz", - "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==", - "dev": true + "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==" } } }, @@ -6963,9 +7105,9 @@ } }, "engine.io-client": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.0.tgz", - "integrity": "sha512-12wPRfMrugVw/DNyJk34GQ5vIVArEcVMXWugQGGuw2XxUSztFNmJggZmv8IZlLyEdnpO1QB9LkcjeWewO2vxtA==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.1.tgz", + "integrity": "sha512-oVu9kBkGbcggulyVF0kz6BV3ganqUeqXvD79WOFKa+11oK692w1NyFkuEj4xrkFRpZhn92QOqTk4RQq5LiBXbQ==", "dev": true, "requires": { "component-emitter": "~1.3.0", @@ -7745,9 +7887,7 @@ "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "optional": true + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, "filename-reserved-regex": { "version": "2.0.0", @@ -8607,7 +8747,6 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dev": true, "requires": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" @@ -8623,7 +8762,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", - "dev": true, "requires": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", @@ -9112,6 +9250,14 @@ "postcss": "^7.0.14" } }, + "idna-uts46-hx": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/idna-uts46-hx/-/idna-uts46-hx-3.4.0.tgz", + "integrity": "sha512-b1I4qYTcJcX1TANn8OhOGrQUIWOfZUWrLKWDeKbV6posVLjp7OTqFKX3N20efrIMzQM1KhiphOEazBEEUFR9bg==", + "requires": { + "punycode": "^2.1.1" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -9320,9 +9466,9 @@ } }, "ionicons": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.2.3.tgz", - "integrity": "sha512-87qtgBkieKVFagwYA9Cf91B3PCahQbEOMwMt8bSvlQSgflZ4eE5qI4MGj2ZlIyadeX0dgo+0CzZsy3ow0CsBAg==" + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.4.0.tgz", + "integrity": "sha512-q8jG3vB87lXz3/OokEDoFsEQVUqdyHq3w2r5bp1OUwODus/njuc8MxjlBE7Z1RLkf6NbNaG+/kJTx3dsCJQwQQ==" }, "ip": { "version": "1.1.5", @@ -9707,7 +9853,8 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true }, "isbinaryfile": { "version": "4.0.6", @@ -10400,46 +10547,11 @@ "ms": "2.0.0" } }, - "lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "requires": { - "immediate": "~3.0.5" - } - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } } } }, @@ -11104,14 +11216,12 @@ "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "minimalistic-crypto-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", - "dev": true + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" }, "minimatch": { "version": "3.0.4", @@ -11324,9 +11434,7 @@ "nan": { "version": "2.14.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", - "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", - "dev": true, - "optional": true + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" }, "nanomatch": { "version": "1.2.13", @@ -13567,7 +13675,8 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true }, "progress": { "version": "2.0.3", @@ -14148,8 +14257,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "puppeteer": { "version": "1.20.0", @@ -17382,6 +17490,25 @@ "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", "dev": true }, + "tiny-secp256k1": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-1.1.6.tgz", + "integrity": "sha512-FmqJZGduTyvsr2cF3375fqGHUovSwDi/QytexX1Se4BPuPZpTE5Ftp5fg+EFSuEf3lhZqgCRjEG3ydUQ/aNiwA==", + "requires": { + "bindings": "^1.3.0", + "bn.js": "^4.11.8", + "create-hmac": "^1.1.7", + "elliptic": "^6.4.0", + "nan": "^2.13.2" + }, + "dependencies": { + "bn.js": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz", + "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==" + } + } + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -17670,6 +17797,11 @@ "is-typedarray": "^1.0.0" } }, + "typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" + }, "typescript": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz", @@ -19238,6 +19370,14 @@ } } }, + "wif": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz", + "integrity": "sha1-CNP1IFbGZnkplyb63g1DKudLRwQ=", + "requires": { + "bs58check": "<3.0.0" + } + }, "wildcard": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", @@ -19253,11 +19393,6 @@ "execa": "^4.0.2" }, "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" - }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -19331,23 +19466,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "requires": { - "ansi-regex": "^3.0.0" - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index b7fef8a0..1d84ca31 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ "apply-diagnostic-modules": "node apply-diagnostic-modules.js" }, "dependencies": { - "@airgap/angular-core": "0.0.8", - "@airgap/coinlib-core": "0.10.5", + "@airgap/angular-core": "0.0.9", + "@airgap/angular-ngrx": "0.0.9", + "@airgap/coinlib-core": "0.11.0", + "@airgap/sapling-wasm": "0.0.6", "@angular/common": "^11.0.4", "@angular/core": "^11.0.4", "@angular/forms": "^11.0.4", @@ -48,12 +50,17 @@ "@ionic-native/device-motion": "^5.19.1", "@ionic-native/diagnostic": "^5.7.0", "@ionic/angular": "5.5.2", + "@ionic/core": "^5.5.4", "@ionic/storage": "2.2.0", + "@ngrx/component-store": "^10.1.2", + "@ngrx/effects": "^10.1.2", + "@ngrx/store": "^10.1.2", "@ngx-translate/core": "^13.0.0", "@zxing/ngx-scanner": "3.0.0", "angular2-uuid": "^1.1.1", "angularx-qrcode": "^11.0.0", "bignumber.js": "^9.0.0", + "bip32": "^2.0.6", "bip39": "^2.4.0", "cordova-plugin-audioinput": "1.0.1", "cordova-plugin-compat": "1.2.0", @@ -70,6 +77,7 @@ "secrets.js-grempe": "^1.1.0", "tslib": "^1.10.0", "tslint-config-valorsoft": "^2.2.1", + "wif": "^2.0.6", "zone.js": "~0.10.2" }, "devDependencies": { @@ -84,20 +92,17 @@ "@capacitor/cli": "^2.4.0", "@ionic/angular-toolkit": "^3.0.0", "@ionic/cli": "^6.11.8", - "@types/node": "^12.11.1", - "jetifier": "^1.6.6", - "replace": "^1.1.0", - "sonarqube-scanner": "2.6.0", - "typescript": "~4.0.5", "@types/core-js": "^2.5.0", "@types/jasmine": "~2.8.8", "@types/jasminewd2": "~2.0.3", + "@types/node": "^12.11.1", "codelyzer": "~4.5.0", "electron": "^5.0.2", "electron-builder": "^20.43.0", "husky": "^2.3.0", "jasmine-core": "~2.99.1", "jasmine-spec-reporter": "~4.2.1", + "jetifier": "^1.6.6", "karma": "~5.1.1", "karma-chrome-launcher": "~2.2.0", "karma-coverage-istanbul-reporter": "~2.0.1", @@ -108,10 +113,13 @@ "pretty-quick": "^1.11.0", "protractor": "~7.0.0", "puppeteer": "^1.17.0", + "replace": "^1.1.0", + "sonarqube-scanner": "2.6.0", "ts-node": "~8.1.0", "tslint": "~6.1.3", "tslint-config-prettier": "^1.18.0", "tslint-plugin-prettier": "^2.0.1", + "typescript": "~4.0.5", "webpack-bundle-analyzer": "^3.6.0" }, "buildDependencies": { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 1ec3b08a..d286a639 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -90,6 +90,18 @@ const routes: Routes = [ path: 'transaction-signed', loadChildren: () => import('./pages/transaction-signed/transaction-signed.module').then((m) => m.TransactionSignedPageModule) }, + { + path: 'bip85-generate', + loadChildren: () => import('./pages/bip85-generate/bip85-generate.module').then((m) => m.Bip85GeneratePageModule) + }, + { + path: 'bip85-show', + loadChildren: () => import('./pages/bip85-show/bip85-show.module').then((m) => m.Bip85ShowPageModule) + }, + { + path: 'bip85-validate', + loadChildren: () => import('./pages/bip85-validate/bip85-validate.module').then((m) => m.Bip85ValidatePageModule) + }, { path: 'select-account', loadChildren: () => import('./pages/select-account/select-account.module').then((m) => m.SelectAccountPageModule) @@ -104,4 +116,4 @@ const routes: Routes = [ imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules, relativeLinkResolution: 'corrected' })], exports: [RouterModule] }) -export class AppRoutingModule {} +export class AppRoutingModule { } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index ac08b71d..2f14face 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -12,13 +12,20 @@ import { SecureStorageServiceMock } from './services/secure-storage/secure-stora import { SecureStorageService } from './services/secure-storage/secure-storage.service' import { StartupChecksService } from './services/startup-checks/startup-checks.service' import { StatusBarPlugin, SplashScreenPlugin, AppPlugin } from '@capacitor/core' -import { SECURITY_UTILS_PLUGIN } from './capacitor-plugins/injection-tokens' -import { SecurityUtilsPlugin } from './capacitor-plugins/definitions' -import { createAppSpy, createSecurityUtilsSpy, createSplashScreenSpy, createStatusBarSpy } from 'test-config/plugins-mocks' +import { SAPLING_PLUGIN, SECURITY_UTILS_PLUGIN } from './capacitor-plugins/injection-tokens' +import { SaplingPlugin, SecurityUtilsPlugin } from './capacitor-plugins/definitions' +import { + createAppSpy, + createSaplingSpy, + createSecurityUtilsSpy, + createSplashScreenSpy, + createStatusBarSpy +} from 'test-config/plugins-mocks' import { IACService } from './services/iac/iac.service' describe('AppComponent', () => { let appSpy: AppPlugin + let saplingSpy: SaplingPlugin let securityUtilsSpy: SecurityUtilsPlugin let statusBarSpy: StatusBarPlugin let splashScreenSpy: SplashScreenPlugin @@ -29,6 +36,7 @@ describe('AppComponent', () => { let unitHelper: UnitHelper beforeEach(() => { appSpy = createAppSpy() + saplingSpy = createSaplingSpy() securityUtilsSpy = createSecurityUtilsSpy() statusBarSpy = createStatusBarSpy() splashScreenSpy = createSplashScreenSpy() @@ -43,6 +51,7 @@ describe('AppComponent', () => { providers: [ { provide: SecureStorageService, useClass: SecureStorageServiceMock }, { provide: APP_PLUGIN, useValue: appSpy }, + { provide: SAPLING_PLUGIN, useValue: saplingSpy }, { provide: SECURITY_UTILS_PLUGIN, useValue: securityUtilsSpy }, { provide: STATUS_BAR_PLUGIN, useValue: statusBarSpy }, { provide: SPLASH_SCREEN_PLUGIN, useValue: splashScreenSpy }, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 8ad47c2c..3377918e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -6,6 +6,13 @@ import { SPLASH_SCREEN_PLUGIN, STATUS_BAR_PLUGIN } from '@airgap/angular-core' +import { NetworkType, TezosProtocolNetwork, TezosSaplingExternalMethodProvider } from '@airgap/coinlib-core' +import { + TezosSaplingProtocolOptions, + TezosShieldedTezProtocolConfig +} from '@airgap/coinlib-core/protocols/tezos/sapling/TezosSaplingProtocolOptions' +import { TezosShieldedTezProtocol } from '@airgap/coinlib-core/protocols/tezos/sapling/TezosShieldedTezProtocol' +import { HttpClient } from '@angular/common/http' import { AfterViewInit, Component, Inject, NgZone } from '@angular/core' import { AppPlugin, AppUrlOpen, SplashScreenPlugin, StatusBarPlugin, StatusBarStyle } from '@capacitor/core' import { Platform } from '@ionic/angular' @@ -19,6 +26,7 @@ import { Secret } from './models/secret' import { ErrorCategory, handleErrorLocal } from './services/error-handler/error-handler.service' import { IACService } from './services/iac/iac.service' import { NavigationService } from './services/navigation/navigation.service' +import { SaplingNativeService } from './services/sapling-native/sapling-native.service' import { SecretsService } from './services/secrets/secrets.service' import { StartupChecksService } from './services/startup-checks/startup-checks.service' @@ -42,6 +50,8 @@ export class AppComponent implements AfterViewInit { private readonly secretsService: SecretsService, private readonly ngZone: NgZone, private readonly navigationService: NavigationService, + private readonly httpClient: HttpClient, + private readonly saplingNativeService: SaplingNativeService, @Inject(APP_PLUGIN) private readonly app: AppPlugin, @Inject(SECURITY_UTILS_PLUGIN) private readonly securityUtils: SecurityUtilsPlugin, @Inject(SPLASH_SCREEN_PLUGIN) private readonly splashScreen: SplashScreenPlugin, @@ -53,9 +63,7 @@ export class AppComponent implements AfterViewInit { } public async initializeApp(): Promise { - await Promise.all([this.platform.ready(), this.initializeTranslations()]) - - this.initializeProtocols() + await Promise.all([this.platform.ready(), this.initializeTranslations(), this.initializeProtocols()]) if (this.platform.is('hybrid')) { this.statusBar.setStyle({ style: StatusBarStyle.Dark }) @@ -116,7 +124,35 @@ export class AppComponent implements AfterViewInit { }) } - private initializeProtocols(): void { - this.protocolService.init() + private async initializeProtocols(): Promise { + const externalMethodProvider: + | TezosSaplingExternalMethodProvider + | undefined = await this.saplingNativeService.createExternalMethodProvider() + + const shieldedTezProtocol: TezosShieldedTezProtocol = new TezosShieldedTezProtocol( + new TezosSaplingProtocolOptions( + new TezosProtocolNetwork('Edonet', NetworkType.TESTNET, 'https://tezos-edonet-node.prod.gke.papers.tech'), + new TezosShieldedTezProtocolConfig(undefined, undefined, undefined, externalMethodProvider) + ) + ) + + this.protocolService.init({ + extraActiveProtocols: [shieldedTezProtocol] + }) + + await shieldedTezProtocol.initParameters(await this.getSaplingParams('spend'), await this.getSaplingParams('output')) + } + + private async getSaplingParams(type: 'spend' | 'output'): Promise { + if (this.platform.is('hybrid')) { + // Sapling params are read and used in a native plugin, there's no need to read them in the Ionic part + return Buffer.alloc(0) + } + + const params: ArrayBuffer = await this.httpClient + .get(`./assets/sapling/sapling-${type}.params`, { responseType: 'arraybuffer' }) + .toPromise() + + return Buffer.from(params) } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 39544fa7..138423d0 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,20 +1,21 @@ import { - APP_CONFIG, - APP_PLUGIN, - APP_INFO_PLUGIN, - CLIPBOARD_PLUGIN, - SPLASH_SCREEN_PLUGIN, - STATUS_BAR_PLUGIN, - PERMISSIONS_PLUGIN, AirGapAngularCoreModule, AirGapTranslateLoader, + APP_CONFIG, + APP_INFO_PLUGIN, + APP_PLUGIN, ClipboardService, + CLIPBOARD_PLUGIN, DeeplinkService, PermissionsService, - SerializerService, + PERMISSIONS_PLUGIN, QrScannerService, + SerializerService, + SPLASH_SCREEN_PLUGIN, + STATUS_BAR_PLUGIN, UiEventService } from '@airgap/angular-core' +import { AirGapAngularNgRxModule } from '@airgap/angular-ngrx' import { HttpClient, HttpClientModule } from '@angular/common/http' import { NgModule } from '@angular/core' import { BrowserModule } from '@angular/platform-browser' @@ -24,11 +25,15 @@ import { DeviceMotion } from '@ionic-native/device-motion/ngx' import { Diagnostic } from '@ionic-native/diagnostic/ngx' import { IonicModule, IonicRouteStrategy, Platform } from '@ionic/angular' import { IonicStorageModule } from '@ionic/storage' +import { EffectsModule } from '@ngrx/effects' +import { StoreModule } from '@ngrx/store' import { TranslateLoader, TranslateModule } from '@ngx-translate/core' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' -import { CAMERA_PREVIEW_PLUGIN, SECURITY_UTILS_PLUGIN } from './capacitor-plugins/injection-tokens' +import * as fromRoot from './app.reducers' +import { CAMERA_PREVIEW_PLUGIN, SAPLING_PLUGIN, SECURITY_UTILS_PLUGIN } from './capacitor-plugins/injection-tokens' +import { appConfig } from './config/app-config' import { DistributionOnboardingPageModule } from './pages/distribution-onboarding/distribution-onboarding.module' import { IntroductionPageModule } from './pages/introduction/introduction.module' import { LocalAuthenticationOnboardingPageModule } from './pages/local-authentication-onboarding/local-authentication-onboarding.module' @@ -50,7 +55,6 @@ import { SecureStorageService } from './services/secure-storage/secure-storage.s import { ShareUrlService } from './services/share-url/share-url.service' import { StartupChecksService } from './services/startup-checks/startup-checks.service' import { VaultStorageService } from './services/storage/storage.service' -import { appConfig } from './config/app-config' export function createTranslateLoader(http: HttpClient): AirGapTranslateLoader { return new AirGapTranslateLoader(http, { prefix: './assets/i18n/', suffix: '.json' }) @@ -61,6 +65,15 @@ export function createTranslateLoader(http: HttpClient): AirGapTranslateLoader { entryComponents: [], imports: [ BrowserModule, + StoreModule.forRoot(fromRoot.reducers, { + metaReducers: fromRoot.metaReducers, + /* temporary fix for `ERROR TypeError: Cannot freeze array buffer views with elements` */ + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false + } + }), + EffectsModule.forRoot(), IonicModule.forRoot(), AppRoutingModule, HttpClientModule, @@ -79,7 +92,8 @@ export function createTranslateLoader(http: HttpClient): AirGapTranslateLoader { IntroductionPageModule, DistributionOnboardingPageModule, LocalAuthenticationOnboardingPageModule, - AirGapAngularCoreModule + AirGapAngularCoreModule, + AirGapAngularNgRxModule ], providers: [ { provide: APP_PLUGIN, useValue: Plugins.App }, @@ -87,6 +101,7 @@ export function createTranslateLoader(http: HttpClient): AirGapTranslateLoader { { provide: CAMERA_PREVIEW_PLUGIN, useValue: Plugins.CameraPreview }, { provide: CLIPBOARD_PLUGIN, useValue: Plugins.Clipboard }, { provide: PERMISSIONS_PLUGIN, useValue: Plugins.Permissions }, + { provide: SAPLING_PLUGIN, useValue: Plugins.SaplingNative }, { provide: SECURITY_UTILS_PLUGIN, useValue: Plugins.SecurityUtils }, { provide: SPLASH_SCREEN_PLUGIN, useValue: Plugins.SplashScreen }, { provide: STATUS_BAR_PLUGIN, useValue: Plugins.StatusBar }, diff --git a/src/app/app.reducers.ts b/src/app/app.reducers.ts new file mode 100644 index 00000000..be387763 --- /dev/null +++ b/src/app/app.reducers.ts @@ -0,0 +1,20 @@ +import { ActionReducer, MetaReducer } from '@ngrx/store' +import { environment } from 'src/environments/environment' + +export interface State {} + +export const reducers = {} + +function logger(reducer: ActionReducer): ActionReducer { + return (state, action) => { + const newState = reducer(state, action) + console.groupCollapsed(action.type) + console.log('previous state', state) + console.log('action', action) + console.log('next state', newState) + console.groupEnd() + + return newState + } +} +export const metaReducers: MetaReducer[] = !environment.production ? [logger] : [] diff --git a/src/app/capacitor-plugins/definitions.ts b/src/app/capacitor-plugins/definitions.ts index 88cd7369..7f5b2bf9 100644 --- a/src/app/capacitor-plugins/definitions.ts +++ b/src/app/capacitor-plugins/definitions.ts @@ -6,6 +6,31 @@ export interface CameraPreviewPlugin { capture({}): Promise<{ value: string }> } +export interface SaplingPlugin { + isSupported(): Promise<{ isSupported: boolean }> + initParameters(): Promise + initProvingContext(): Promise<{ context: string }> + dropProvingContext(params: { context: string }): Promise + prepareSpendDescription(params: { + context: string + spendingKey: string + address: string + rcm: string + ar: string + value: string + root: string + merklePath: string + }): Promise<{ spendDescription: string }> + preparePartialOutputDescription(params: { + context: string + address: string + rcm: string + esk: string + value: string + }): Promise<{ outputDescription: string }> + createBindingSignature(params: { context: string; balance: string; sighash: string }): Promise<{ bindingSignature: string }> +} + export interface SecurityUtilsPlugin { waitForOverlayDismiss(): Promise assessDeviceIntegrity(): Promise<{ value: boolean }> diff --git a/src/app/capacitor-plugins/injection-tokens.ts b/src/app/capacitor-plugins/injection-tokens.ts index 4c215890..cd73c546 100644 --- a/src/app/capacitor-plugins/injection-tokens.ts +++ b/src/app/capacitor-plugins/injection-tokens.ts @@ -1,5 +1,6 @@ import { InjectionToken } from '@angular/core' -import { CameraPreviewPlugin, SecurityUtilsPlugin } from './definitions' +import { CameraPreviewPlugin, SaplingPlugin, SecurityUtilsPlugin } from './definitions' export const CAMERA_PREVIEW_PLUGIN = new InjectionToken('CameraPreviewPlugin') +export const SAPLING_PLUGIN = new InjectionToken('SaplingPlugin') export const SECURITY_UTILS_PLUGIN = new InjectionToken('SecurityUtilsPlugin') diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index 44e7b327..135f4ca6 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -18,7 +18,7 @@ import { SecretItemComponent } from './secret-item/secret-item.component' import { SignedTransactionComponent } from './signed-transaction/signed-transaction.component' import { TouchEntropyComponent } from './touch-entropy/touch-entropy.component' import { TraceInputDirective } from './trace-input/trace-input.directive' -import { UnsignedTransactionComponent } from './unsigned-transaction/unsigned-transaction.component' +import { TransactionComponent } from './transaction/transaction.component' import { VerifyKeyComponent } from './verify-key/verify-key.component' @NgModule({ @@ -28,7 +28,7 @@ import { VerifyKeyComponent } from './verify-key/verify-key.component' ProgressFooterComponent, SecretItemComponent, SignedTransactionComponent, - UnsignedTransactionComponent, + TransactionComponent, TouchEntropyComponent, TraceInputDirective, VerifyKeyComponent, @@ -43,7 +43,7 @@ import { VerifyKeyComponent } from './verify-key/verify-key.component' ProgressFooterComponent, SecretItemComponent, SignedTransactionComponent, - UnsignedTransactionComponent, + TransactionComponent, TouchEntropyComponent, TraceInputDirective, VerifyKeyComponent, diff --git a/src/app/components/current-secret/current-secret.component.html b/src/app/components/current-secret/current-secret.component.html index a909adcb..3b6eea0b 100644 --- a/src/app/components/current-secret/current-secret.component.html +++ b/src/app/components/current-secret/current-secret.component.html @@ -1,9 +1,19 @@ - {{ 'current-secret.label' | translate }} + {{ 'current-secret.label' | translate }}

{{ (currentSecret$ | async).label }}

- - {{ secret.label }} + + + + {{ secret.label }} + +
diff --git a/src/app/components/message-sign-request/message-sign-request.component.html b/src/app/components/message-sign-request/message-sign-request.component.html index 542449f4..12f7917e 100644 --- a/src/app/components/message-sign-request/message-sign-request.component.html +++ b/src/app/components/message-sign-request/message-sign-request.component.html @@ -1,18 +1,12 @@ - +

{{ 'message-signing-request.payload_label' | translate }}

-
{{ message | json }}
+
{{ messages[0].data | json }}
- +

{{ 'message-signing-request.blake2b_hash' | translate }}

-
{{ blake2bHash }}
+
{{ messages[0].blake2bHash }}
- - - - {{ 'message-signing-request.sign_button' | translate }} - - diff --git a/src/app/components/message-sign-request/message-sign-request.component.ts b/src/app/components/message-sign-request/message-sign-request.component.ts index d95f9faf..34de8f7b 100644 --- a/src/app/components/message-sign-request/message-sign-request.component.ts +++ b/src/app/components/message-sign-request/message-sign-request.component.ts @@ -1,24 +1,4 @@ -import { SelectAccountPage } from './../../pages/select-account/select-account.page' import { Component, Input } from '@angular/core' -import { - IACMessageDefinitionObject, - MessageSignRequest, - MessageSignResponse, - ICoinProtocol, - IACMessageType, - ProtocolSymbols, - MainProtocolSymbols, - TezosCryptoClient -} from '@airgap/coinlib-core' -import { InteractionService, InteractionOperationType } from 'src/app/services/interaction/interaction.service' -import { SecretsService } from 'src/app/services/secrets/secrets.service' -import { handleErrorLocal, ErrorCategory } from 'src/app/services/error-handler/error-handler.service' -import * as bip39 from 'bip39' -import { Secret } from 'src/app/models/secret' -import { AlertController, ModalController } from '@ionic/angular' -import { ProtocolService, SerializerService } from '@airgap/angular-core' -import { NavigationService } from 'src/app/services/navigation/navigation.service' - @Component({ selector: 'airgap-message-sign-request', templateUrl: './message-sign-request.component.html', @@ -26,128 +6,10 @@ import { NavigationService } from 'src/app/services/navigation/navigation.servic }) export class MessageSignRequestComponent { @Input() - public messageDefinitionObject: IACMessageDefinitionObject - - public message: string - public blake2bHash: string | undefined - - @Input() - public syncProtocolString: string - - constructor( - private readonly interactionService: InteractionService, - private readonly navigationService: NavigationService, - private readonly protocolService: ProtocolService, - private readonly secretsService: SecretsService, - private readonly serializerService: SerializerService, - private readonly alertCtrl: AlertController, - private readonly modalController: ModalController - ) { } - - public async ngOnInit() { - this.message = (this.messageDefinitionObject.payload as MessageSignRequest).message - if (this.messageDefinitionObject.protocol === MainProtocolSymbols.XTZ) { - const cryptoClient = new TezosCryptoClient() - this.blake2bHash = await cryptoClient.blake2bLedgerHash(this.message) - } - } - - public async signAndGoToNextPage(): Promise { - try { - const protocol = await this.getSigningProtocol() - if (!protocol) { - this.navigationService.route('') - return - } - let secret: Secret - const pubKey = (this.messageDefinitionObject.payload as MessageSignRequest).publicKey - if (pubKey !== undefined && pubKey.length > 0) { - secret = this.secretsService.findByPublicKey(pubKey) - } else { - secret = this.secretsService.getActiveSecret() - } - - // we should handle this case here as well - if (!secret) { - console.warn('no secret found for this public key') - throw new Error('no secret found for this public key') - } - - const entropy = await this.secretsService.retrieveEntropyForSecret(secret) - - const mnemonic: string = bip39.entropyToMnemonic(entropy) - const privateKey: Buffer = await protocol.getPrivateKeyFromMnemonic(mnemonic, protocol.standardDerivationPath) // TODO - - const signature = await protocol.signMessage(this.message, { privateKey }) - const messageSignRequest = this.messageDefinitionObject.payload as MessageSignRequest - const messageSignResponse: MessageSignResponse = { - message: messageSignRequest.message, - publicKey: messageSignRequest.publicKey, - signature: signature - } - - const messageDefinitionObject = { - id: this.messageDefinitionObject.id, - type: IACMessageType.MessageSignResponse, - protocol: (await this.getSigningProtocol()).identifier as ProtocolSymbols, - payload: messageSignResponse - } - - const serializedMessage: string[] = await this.serializerService.serialize([messageDefinitionObject]) - const broadcastUrl = `airgap-wallet://?d=${serializedMessage.join(',')}` - this.interactionService.startInteraction( - { - operationType: InteractionOperationType.MESSAGE_SIGN_REQUEST, - url: broadcastUrl, - messageSignResponse: messageSignResponse - }, - this.secretsService.getActiveSecret() - ) - } catch (error) { - console.log('Caught error: ', error) - if (error.message) { - this.showAlert('Error', error.message) - } - } - } - - public async getSigningProtocol(): Promise { - let protocol - if (!this.messageDefinitionObject.protocol || this.messageDefinitionObject.protocol.length === 0) { - const modal: HTMLIonModalElement = await this.modalController.create({ - component: SelectAccountPage, - componentProps: { type: 'message-signing' } - }) - modal.present().catch(handleErrorLocal(ErrorCategory.IONIC_MODAL)) - await modal - .onDidDismiss() - .then((modalData) => { - console.log() - if (!modalData.data) { - return - } - const identifier = modalData.data - protocol = this.protocolService.getProtocol(identifier) - }) - .catch(handleErrorLocal(ErrorCategory.IONIC_MODAL)) - } else { - protocol = this.protocolService.getProtocol(this.messageDefinitionObject.protocol) - } - return protocol - } + public messages: { + data: string + blake2bHash?: string + }[] - public async showAlert(title: string, message: string): Promise { - const alert: HTMLIonAlertElement = await this.alertCtrl.create({ - header: title, - message, - backdropDismiss: false, - buttons: [ - { - text: 'Okay!', - role: 'cancel' - } - ] - }) - alert.present().catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) - } + constructor() {} } diff --git a/src/app/components/signed-transaction/signed-transaction.component.spec.ts b/src/app/components/signed-transaction/signed-transaction.component.spec.ts index 4b3a1b08..a606ec59 100644 --- a/src/app/components/signed-transaction/signed-transaction.component.spec.ts +++ b/src/app/components/signed-transaction/signed-transaction.component.spec.ts @@ -5,6 +5,9 @@ import { SignedTransactionComponent } from './signed-transaction.component' import { MainProtocolSymbols } from '@airgap/coinlib-core/utils/ProtocolSymbols' import { IACMessageType, Serializer } from '@airgap/coinlib-core' import { Message } from '@airgap/coinlib-core/serializer/message' +import { SecretsService } from 'src/app/services/secrets/secrets.service' +import { SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' +import { SecureStorageServiceMock } from 'src/app/services/secure-storage/secure-storage.mock' describe('SignedTransactionComponent', () => { let signedTransactionFixture: ComponentFixture @@ -15,7 +18,8 @@ describe('SignedTransactionComponent', () => { unitHelper = new UnitHelper() TestBed.configureTestingModule( unitHelper.testBed({ - declarations: [] + declarations: [], + providers: [{ provide: SecureStorageService, useClass: SecureStorageServiceMock }, SecretsService] }) ) .compileComponents() diff --git a/src/app/components/signed-transaction/signed-transaction.component.ts b/src/app/components/signed-transaction/signed-transaction.component.ts index ed699716..3162f326 100644 --- a/src/app/components/signed-transaction/signed-transaction.component.ts +++ b/src/app/components/signed-transaction/signed-transaction.component.ts @@ -1,8 +1,17 @@ -import { ProtocolService, SerializerService } from '@airgap/angular-core' +import { ProtocolService, SerializerService, sumAirGapTxValues } from '@airgap/angular-core' import { Component, Input } from '@angular/core' -import { IACMessageDefinitionObject, IAirGapTransaction, ICoinProtocol, SignedTransaction } from '@airgap/coinlib-core' +import { + IACMessageDefinitionObject, + IAirGapTransaction, + ICoinProtocol, + MainProtocolSymbols, + ProtocolSymbols, + SignedTransaction, + TezosSaplingProtocol +} from '@airgap/coinlib-core' import BigNumber from 'bignumber.js' import { TokenService } from 'src/app/services/token/TokenService' +import { SecretsService } from 'src/app/services/secrets/secrets.service' @Component({ selector: 'airgap-signed-transaction', @@ -30,7 +39,8 @@ export class SignedTransactionComponent { constructor( private readonly protocolService: ProtocolService, private readonly serializerService: SerializerService, - private readonly tokenService: TokenService + private readonly tokenService: TokenService, + private readonly secretsService: SecretsService ) { // } @@ -52,7 +62,17 @@ export class SignedTransactionComponent { // tslint:disable-next-line:no-unnecessary-type-assertion this.airGapTxs = ( await Promise.all( - this.signedTxs.map((signedTx) => protocol.getTransactionDetailsFromSigned(signedTx.payload as SignedTransaction)) + this.signedTxs.map(async (signedTx) => { + const payload: SignedTransaction = signedTx.payload as SignedTransaction + if (await this.checkIfSaplingTransaction(payload, signedTx.protocol)) { + const saplingProtocol = await this.getSaplingProtocol() + return saplingProtocol.getTransactionDetailsFromSigned(payload, { + knownViewingKeys: this.secretsService.getKnownViewingKeys() + }) + } else { + return protocol.getTransactionDetailsFromSigned(payload) + } + }) ) ).reduce((flatten, toFlatten) => flatten.concat(toFlatten)) if ( @@ -61,8 +81,8 @@ export class SignedTransactionComponent { ) { this.aggregatedInfo = { numberOfTxs: this.airGapTxs.length, - totalAmount: this.airGapTxs.reduce((pv: BigNumber, cv: IAirGapTransaction) => pv.plus(cv.amount), new BigNumber(0)), - totalFees: this.airGapTxs.reduce((pv: BigNumber, cv: IAirGapTransaction) => pv.plus(cv.fee), new BigNumber(0)) + totalAmount: new BigNumber(sumAirGapTxValues(this.airGapTxs, 'amount')), + totalFees: new BigNumber(sumAirGapTxValues(this.airGapTxs, 'fee')) } } try { @@ -84,4 +104,25 @@ export class SignedTransactionComponent { } } } + + private async checkIfSaplingTransaction(transaction: SignedTransaction, protocolIdentifier: ProtocolSymbols): Promise { + if (protocolIdentifier === MainProtocolSymbols.XTZ) { + const tezosProtocol: ICoinProtocol = await this.protocolService.getProtocol(protocolIdentifier) + const saplingProtocol: TezosSaplingProtocol = await this.getSaplingProtocol() + + const txDetails: IAirGapTransaction[] = await tezosProtocol.getTransactionDetailsFromSigned(transaction) + const recipients: string[] = txDetails + .map((details) => details.to) + .reduce((flatten: string[], next: string[]) => flatten.concat(next), []) + + console.log(recipients) + return recipients.includes(saplingProtocol.options.config.contractAddress) + } + + return protocolIdentifier === MainProtocolSymbols.XTZ_SHIELDED + } + + private async getSaplingProtocol(): Promise { + return (await this.protocolService.getProtocol(MainProtocolSymbols.XTZ_SHIELDED)) as TezosSaplingProtocol + } } diff --git a/src/app/components/transaction/transaction.component.html b/src/app/components/transaction/transaction.component.html new file mode 100644 index 00000000..2fde5dfb --- /dev/null +++ b/src/app/components/transaction/transaction.component.html @@ -0,0 +1,32 @@ + + + + +
+ {{ + aggregatedDetails.totalAmount.toFixed() + | amountConverter: { protocol: protocolIdentifier$ | async, maxDigits: undefined } + | async + }} +
+
+ +
{{ aggregatedDetails.numberOfTxs }}
+
+ +
{{ aggregatedDetails.totalFees.toFixed() | feeConverter: { protocol: protocolIdentifier$ | async } | async }}
+
+ + Amount + + + Operations + + + Fee + +
+
+ + +
diff --git a/src/app/components/transaction/transaction.component.scss b/src/app/components/transaction/transaction.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/components/transaction/transaction.component.spec.ts b/src/app/components/transaction/transaction.component.spec.ts new file mode 100644 index 00000000..21c21647 --- /dev/null +++ b/src/app/components/transaction/transaction.component.spec.ts @@ -0,0 +1,46 @@ +import { SecretsService } from 'src/app/services/secrets/secrets.service' +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { UnitHelper } from '../../../../test-config/unit-test-helper' + +import { TransactionComponent } from './transaction.component' +import { SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' +import { SecureStorageServiceMock } from 'src/app/services/secure-storage/secure-storage.mock' +import { InteractionService } from 'src/app/services/interaction/interaction.service' +import { createAppSpy } from 'test-config/plugins-mocks' +import { APP_PLUGIN, DeeplinkService } from '@airgap/angular-core' + +describe('UnsignedTransactionComponent', () => { + let signedTransactionFixture: ComponentFixture + let unsignedTransaction: TransactionComponent + + let unitHelper: UnitHelper + beforeEach(() => { + const appSpy = createAppSpy() + + unitHelper = new UnitHelper() + TestBed.configureTestingModule( + unitHelper.testBed({ + declarations: [], + providers: [ + { provide: SecureStorageService, useClass: SecureStorageServiceMock }, + SecretsService, + InteractionService, + DeeplinkService, + { provide: APP_PLUGIN, useValue: appSpy } + ] + }) + ) + .compileComponents() + .catch(console.error) + }) + + beforeEach(async () => { + signedTransactionFixture = TestBed.createComponent(TransactionComponent) + unsignedTransaction = signedTransactionFixture.componentInstance + }) + + it('should be created', () => { + expect(unsignedTransaction instanceof TransactionComponent).toBe(true) + }) +}) diff --git a/src/app/components/transaction/transaction.component.ts b/src/app/components/transaction/transaction.component.ts new file mode 100644 index 00000000..58568516 --- /dev/null +++ b/src/app/components/transaction/transaction.component.ts @@ -0,0 +1,32 @@ +import { IAirGapTransaction, ProtocolSymbols } from '@airgap/coinlib-core' +import { Component, Input, OnInit } from '@angular/core' +import { Observable } from 'rxjs' + +import { AggregatedDetails, TransactionStore } from './transaction.store' + +@Component({ + selector: 'airgap-transaction', + templateUrl: './transaction.component.html', + styleUrls: ['./transaction.component.scss'], + providers: [TransactionStore] +}) +export class TransactionComponent implements OnInit { + @Input() + public airGapTxs: IAirGapTransaction[] | undefined + + public protocolIdentifier$: Observable + public airGapTxs$: Observable + public aggregatedDetails$: Observable + + constructor(private readonly store: TransactionStore) { + this.protocolIdentifier$ = this.store.selectProtocolIdentifier() + this.airGapTxs$ = this.store.selectAirGapTxs() + this.aggregatedDetails$ = this.store.selectAggregatedDetails() + } + + public async ngOnInit(): Promise { + if (this.airGapTxs !== undefined) { + this.store.setAirGapTxs(this.airGapTxs) + } + } +} diff --git a/src/app/components/transaction/transaction.store.ts b/src/app/components/transaction/transaction.store.ts new file mode 100644 index 00000000..fd5e2b70 --- /dev/null +++ b/src/app/components/transaction/transaction.store.ts @@ -0,0 +1,52 @@ +import { sumAirGapTxValues } from '@airgap/angular-core' +import { IAirGapTransaction, ProtocolSymbols } from '@airgap/coinlib-core' +import { Injectable } from '@angular/core' +import { ComponentStore } from '@ngrx/component-store' +import BigNumber from 'bignumber.js' + +export interface AggregatedDetails { + numberOfTxs: number + totalAmount: BigNumber + totalFees: BigNumber +} + +export interface TransactionState { + protocolIdentifier: ProtocolSymbols | undefined + airGapTxs: IAirGapTransaction[] + aggregatedDetails: AggregatedDetails | undefined +} + +const initialState: TransactionState = { + protocolIdentifier: undefined, + airGapTxs: [], + aggregatedDetails: undefined +} + +@Injectable() +export class TransactionStore extends ComponentStore { + constructor() { + super(initialState) + } + + public selectProtocolIdentifier() { + return this.select((state: TransactionState) => state.protocolIdentifier) + } + + public selectAirGapTxs() { + return this.select((state: TransactionState) => state.airGapTxs) + } + + public selectAggregatedDetails() { + return this.select((state: TransactionState) => state.aggregatedDetails) + } + + public readonly setAirGapTxs = this.updater((_state: TransactionState, airGapTxs: IAirGapTransaction[]) => ({ + protocolIdentifier: airGapTxs[0].protocolIdentifier, + airGapTxs, + aggregatedDetails: { + numberOfTxs: airGapTxs.length, + totalAmount: new BigNumber(sumAirGapTxValues(airGapTxs, 'amount')), + totalFees: new BigNumber(sumAirGapTxValues(airGapTxs, 'fee')) + } + })) +} diff --git a/src/app/components/unsigned-transaction/unsigned-transaction.component.html b/src/app/components/unsigned-transaction/unsigned-transaction.component.html deleted file mode 100644 index aebeddb7..00000000 --- a/src/app/components/unsigned-transaction/unsigned-transaction.component.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - -
- {{ - aggregatedInfo.totalAmount.toFixed() - | amountConverter: { protocol: airGapTxs[0].protocolIdentifier, maxDigits: undefined } - | async - }} -
-
- -
{{ aggregatedInfo.numberOfTxs }}
-
- -
{{ aggregatedInfo.totalFees.toFixed() | feeConverter: { protocol: airGapTxs[0].protocolIdentifier } | async }}
-
- - Amount - - - Operations - - - Fee - -
-
- - - - - - - - - {{ 'signed-transaction.transaction-unreadable' | translate }} - - - -

{{ rawTxData }}

-
-
-
-
- - - - - {{ 'unsigned-transaction.sign-tx_label' | translate }} - - diff --git a/src/app/components/unsigned-transaction/unsigned-transaction.component.scss b/src/app/components/unsigned-transaction/unsigned-transaction.component.scss deleted file mode 100644 index b3434cbd..00000000 --- a/src/app/components/unsigned-transaction/unsigned-transaction.component.scss +++ /dev/null @@ -1,6 +0,0 @@ -.warning-icon { - font-size: 4rem; -} -.word-break__all { - word-break: break-all; -} diff --git a/src/app/components/unsigned-transaction/unsigned-transaction.component.spec.ts b/src/app/components/unsigned-transaction/unsigned-transaction.component.spec.ts deleted file mode 100644 index 23b77069..00000000 --- a/src/app/components/unsigned-transaction/unsigned-transaction.component.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { SecretsService } from 'src/app/services/secrets/secrets.service' -import { async, ComponentFixture, TestBed } from '@angular/core/testing' -import { IACMessageType, Serializer } from '@airgap/coinlib-core' - -import { UnitHelper } from '../../../../test-config/unit-test-helper' - -import { UnsignedTransactionComponent } from './unsigned-transaction.component' -import { MainProtocolSymbols } from '@airgap/coinlib-core/utils/ProtocolSymbols' -import { Message } from '@airgap/coinlib-core/serializer/message' -import { SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' -import { SecureStorageServiceMock } from 'src/app/services/secure-storage/secure-storage.mock' -import { InteractionService } from 'src/app/services/interaction/interaction.service' -import { createAppSpy } from 'test-config/plugins-mocks' -import { APP_PLUGIN, DeeplinkService } from '@airgap/angular-core' - -describe('UnsignedTransactionComponent', () => { - let signedTransactionFixture: ComponentFixture - let unsignedTransaction: UnsignedTransactionComponent - - let unitHelper: UnitHelper - beforeEach(() => { - const appSpy = createAppSpy() - - unitHelper = new UnitHelper() - TestBed.configureTestingModule( - unitHelper.testBed({ - declarations: [], - providers: [ - { provide: SecureStorageService, useClass: SecureStorageServiceMock }, - SecretsService, - InteractionService, - DeeplinkService, - { provide: APP_PLUGIN, useValue: appSpy } - ] - }) - ) - .compileComponents() - .catch(console.error) - }) - - beforeEach(async () => { - signedTransactionFixture = TestBed.createComponent(UnsignedTransactionComponent) - unsignedTransaction = signedTransactionFixture.componentInstance - }) - - it('should be created', () => { - expect(unsignedTransaction instanceof UnsignedTransactionComponent).toBe(true) - }) - - it('should load the from-to component if a valid tx is given', async(async () => { - const serializer: Serializer = new Serializer() - const serializedTxs = await serializer.serialize([ - new Message( - IACMessageType.TransactionSignResponse, - - MainProtocolSymbols.ETH, - { - accountIdentifier: 'test', - transaction: - 'f86c808504a817c800825208944a1e1d37462a422873bfccb1e705b05cc4bd922e880de0b6b3a76400008026a00678aaa8f8fd478952bf46044589f5489e809c5ae5717dfe6893490b1f98b441a06a82b82dad7c3232968ec3aa2bba32879b3ecdb877934915d7e65e095fe53d5d' - } - ) - ]) - - expect(unsignedTransaction.airGapTxs).toBe(undefined) - expect(unsignedTransaction.fallbackActivated).toBe(false) - - const unsignedTxs = await serializer.deserialize(serializedTxs) - unsignedTransaction.unsignedTxs = unsignedTxs - await unsignedTransaction.ngOnChanges() - })) - - it('should load fallback if something about the TX is wrong', async(async () => { - /* - const syncProtocol = new SyncProtocolUtils() - const serializedTx = await syncProtocol.serialize({ - version: 1, - protocol: 'eth', - type: EncodedType.SIGNED_TRANSACTION, - payload: { - accountIdentifier: 'test', - transaction: - 'asdasdasdasdsad944a1e1d37462a422873bfccb1e705b05cc4bd922e880de0b6b3a76400008026a00678aaa8f8fd478952bf46044589f5489e809c5ae5717dfe6893490b1f98b441a06a82b82dad7c3232968ec3aa2bba32879b3ecdb877934915d7e65e095fe53d5d' - } - }) - - expect(signedTransaction.airGapTxs).toBe(undefined) - expect(signedTransaction.fallbackActivated).toBe(false) - - const signedTx = await syncProtocol.deserialize(serializedTx) - - signedTransaction.signedTx = signedTx - await signedTransaction.ngOnChanges() - - expect(signedTransaction.airGapTxs).toBeUndefined() - expect(signedTransaction.fallbackActivated).toBe(true) - */ - })) -}) diff --git a/src/app/components/unsigned-transaction/unsigned-transaction.component.ts b/src/app/components/unsigned-transaction/unsigned-transaction.component.ts deleted file mode 100644 index 78da5eac..00000000 --- a/src/app/components/unsigned-transaction/unsigned-transaction.component.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { Component, Input, OnChanges } from '@angular/core' -import { - IACMessageDefinitionObject, - IAirGapTransaction, - UnsignedTransaction, - AirGapWallet, - IACMessageType, - ICoinProtocol -} from '@airgap/coinlib-core' -import BigNumber from 'bignumber.js' -import * as bip39 from 'bip39' -import { ProtocolService, SerializerService } from '@airgap/angular-core' -import { handleErrorLocal, ErrorCategory } from 'src/app/services/error-handler/error-handler.service' -import { Secret } from 'src/app/models/secret' -import { SecretsService } from 'src/app/services/secrets/secrets.service' -import { InteractionOperationType, InteractionService } from 'src/app/services/interaction/interaction.service' -import { AlertController } from '@ionic/angular' -import { TokenService } from 'src/app/services/token/TokenService' -import { SignTransactionInfo } from 'src/app/models/sign-transaction-info' - -@Component({ - selector: 'airgap-unsigned-transaction', - templateUrl: './unsigned-transaction.component.html', - styleUrls: ['./unsigned-transaction.component.scss'] -}) -export class UnsignedTransactionComponent implements OnChanges { - @Input() - public unsignedTxs: IACMessageDefinitionObject[] | undefined // TODO: Type - - @Input() - public transactionInfos: SignTransactionInfo[] - - @Input() - public syncProtocolString: string - - public airGapTxs: IAirGapTransaction[] - public fallbackActivated: boolean = false - public broadcastUrl?: string - public transactionsWithWallets: [UnsignedTransaction, AirGapWallet][] - - public aggregatedInfo: - | { - numberOfTxs: number - totalAmount: BigNumber - totalFees: BigNumber - } - | undefined - - public rawTxData: string - - constructor( - private readonly alertController: AlertController, - private readonly protocolService: ProtocolService, - private readonly serializerService: SerializerService, - private readonly secretsService: SecretsService, - private readonly interactionService: InteractionService, - private readonly tokenService: TokenService - ) { } - - public async ionViewWillEnter(): Promise { - try { - this.airGapTxs = ( - await Promise.all( - this.transactionInfos.map((info) => - info.wallet.protocol.getTransactionDetails(info.signTransactionRequest.payload as UnsignedTransaction) - ) - ) - ).reduce((flatten, toFlatten) => flatten.concat(toFlatten), []) - } catch (e) { - console.error('cannot read tx details', e) - } - } - - public async ngOnChanges(): Promise { - if (this.unsignedTxs && this.unsignedTxs.length > 0) { - const protocol: ICoinProtocol = await this.protocolService.getProtocol(this.unsignedTxs[0].protocol) - try { - // tslint:disable-next-line:no-unnecessary-type-assertion - const unsignedTransaction: UnsignedTransaction = this.unsignedTxs[0].payload as UnsignedTransaction - this.airGapTxs = ( - await Promise.all(this.unsignedTxs.map((unsignedTx) => protocol.getTransactionDetails(unsignedTx.payload as UnsignedTransaction))) - ).reduce((flatten, toFlatten) => flatten.concat(toFlatten), []) - - if ( - this.airGapTxs.length > 1 && - this.airGapTxs.every((tx: IAirGapTransaction) => tx.protocolIdentifier === this.airGapTxs[0].protocolIdentifier) - ) { - this.aggregatedInfo = { - numberOfTxs: this.airGapTxs.length, - totalAmount: this.airGapTxs.reduce((pv: BigNumber, cv: IAirGapTransaction) => pv.plus(cv.amount), new BigNumber(0)), - totalFees: this.airGapTxs.reduce((pv: BigNumber, cv: IAirGapTransaction) => pv.plus(cv.fee), new BigNumber(0)) - } - } - - try { - if (this.airGapTxs.length !== 1) { - throw Error('TokenTransferDetails returned more than 1 transaction!') - } - this.airGapTxs = [await this.tokenService.getTokenTransferDetails(this.airGapTxs[0], unsignedTransaction)] - } catch (error) { - console.error('unable to parse token transaction, using ethereum transaction details instead') - } - - this.fallbackActivated = false - } catch (e) { - this.fallbackActivated = true - // tslint:disable-next-line:no-unnecessary-type-assertion - this.rawTxData = JSON.stringify((this.unsignedTxs[0].payload as UnsignedTransaction).transaction) - } - } - } - - public async signAndGoToNextPage(): Promise { - try { - const signedTxs: string[] = await Promise.all( - this.transactionInfos.map((info) => this.signTransaction(info.signTransactionRequest.payload as UnsignedTransaction, info.wallet)) - ) - this.broadcastUrl = await this.generateBroadcastUrl(this.transactionInfos, signedTxs) - - this.interactionService.startInteraction( - { - operationType: InteractionOperationType.TRANSACTION_BROADCAST, - url: this.broadcastUrl, - wallets: this.transactionInfos.map((info) => info.wallet), - signedTxs, - transactions: this.transactionInfos.map((info) => info.signTransactionRequest.payload as UnsignedTransaction) - }, - this.secretsService.getActiveSecret() - ) - } catch (error) { - if (error.message) { - this.showAlert('Error', error.message) - } - } - } - - private async generateBroadcastUrl(transactionInfos: SignTransactionInfo[], signedTxs: string[]): Promise { - let txDetails: IAirGapTransaction[] | undefined - - try { - const transactions = ( - await Promise.all( - transactionInfos.map((info) => - info.wallet.protocol.getTransactionDetails(info.signTransactionRequest.payload as UnsignedTransaction) - ) - ) - ).reduce((flatten, toFlatten) => flatten.concat(toFlatten), []) - - txDetails = transactions - } catch (e) { - handleErrorLocal(e) - } - - if (txDetails && txDetails.length > 0) { - const deserializedTxSigningRequests: IACMessageDefinitionObject[] = transactionInfos.map((info, index) => ({ - id: info.signTransactionRequest.id, - protocol: info.wallet.protocol.identifier, - type: IACMessageType.TransactionSignResponse, - payload: { - accountIdentifier: info.wallet.publicKey.substr(-6), - transaction: signedTxs[index], - from: txDetails[index].from, - amount: txDetails[index].amount, - fee: txDetails[index].fee, - to: txDetails[index].to - } - })) - - const serializedTx: string[] = await this.serializerService.serialize(deserializedTxSigningRequests) - - const unsignedTransaction = transactionInfos[0].signTransactionRequest.payload as UnsignedTransaction - return `${unsignedTransaction.callbackURL || 'airgap-wallet://?d='}${serializedTx.join(',')}` - } else { - throw new Error('Could not get transaction details') - } - } - - public async signTransaction(transaction: UnsignedTransaction, wallet: AirGapWallet): Promise { - const secret: Secret | undefined = this.secretsService.findByPublicKey(wallet.publicKey) - - // we should handle this case here as well - if (!secret) { - console.warn('no secret found for this public key') - throw new Error('no secret found for this public key') - } - - const entropy = await this.secretsService.retrieveEntropyForSecret(secret) - const mnemonic: string = bip39.entropyToMnemonic(entropy) - - if (await this.checkIfPublicKeysMatch(transaction, wallet, mnemonic, '')) { - // Public keys match, so no BIP-39 passphrase has been set - return this.sign(transaction, wallet, mnemonic, '') - } - - return this.sign(transaction, wallet, mnemonic, await this.showBip39PassphraseAlert()) - } - - private async showBip39PassphraseAlert(): Promise { - return new Promise(async (resolve) => { - const alert: HTMLIonAlertElement = await this.alertController.create({ - header: 'BIP-39 Passphrase', - message: 'If you have set a BIP-39 passphrase, please enter it here.', - backdropDismiss: false, - inputs: [ - { - name: 'bip39Passphrase', - type: 'password', - placeholder: 'Passphrase' - } - ], - buttons: [ - { - text: 'Ok', - handler: async (result) => { - const bip39Passphrase = result.bip39Passphrase ?? '' - - resolve(bip39Passphrase) - } - } - ] - }) - alert.present().catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) - }) - } - - private async showBip39PassphraseMismatchAlert(): Promise { - const alert: HTMLIonAlertElement = await this.alertController.create({ - header: 'BIP-39 Passphrase', - message: 'Public keys do not match. Did you enter the correct BIP-39 Passphrase?', - backdropDismiss: false, - buttons: [ - { - text: 'Ok' - } - ] - }) - alert.present().catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) - throw new Error('Public keys do not match. Did you enter the correct BIP-39 Passphrase?') - } - - private async sign( - transaction: UnsignedTransaction, - wallet: AirGapWallet, - mnemonic: string, - bip39Passphrase: string = '' - ): Promise { - if (wallet.isExtendedPublicKey) { - const extendedPrivateKey: string = await wallet.protocol.getExtendedPrivateKeyFromMnemonic( - mnemonic, - wallet.derivationPath, - bip39Passphrase - ) - if (!(await this.checkIfPublicKeysMatch(transaction, wallet, mnemonic, bip39Passphrase))) { - throw this.showBip39PassphraseMismatchAlert() - } - - return wallet.protocol.signWithExtendedPrivateKey(extendedPrivateKey, transaction.transaction) - } else { - const privateKey: Buffer = await wallet.protocol.getPrivateKeyFromMnemonic(mnemonic, wallet.derivationPath, bip39Passphrase) - - if (!(await this.checkIfPublicKeysMatch(transaction, wallet, mnemonic, bip39Passphrase))) { - throw this.showBip39PassphraseMismatchAlert() - } - - return wallet.protocol.signWithPrivateKey(privateKey, transaction.transaction) - } - } - - public async showAlert(title: string, message: string): Promise { - const alert: HTMLIonAlertElement = await this.alertController.create({ - header: title, - message, - backdropDismiss: false, - buttons: [ - { - text: 'Okay!', - role: 'cancel' - } - ] - }) - alert.present().catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) - } - - private async checkIfPublicKeysMatch( - transaction: UnsignedTransaction, - wallet: AirGapWallet, - mnemonic: string, - bip39Passphrase: string = '' - ) { - const publicKey: string = await wallet.protocol.getPublicKeyFromMnemonic(mnemonic, wallet.derivationPath, bip39Passphrase) - - return transaction.publicKey === publicKey - } -} diff --git a/src/app/models/BIP85.spec.ts b/src/app/models/BIP85.spec.ts new file mode 100644 index 00000000..7c40e53f --- /dev/null +++ b/src/app/models/BIP85.spec.ts @@ -0,0 +1,83 @@ +import { BIP85 } from './BIP85' + +// tslint:disable:no-console + +// Test vectors taken from: https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#applications + +// Mnemonic: install scatter logic circle pencil average fall shoe quantum disease suspect usage +const rootKey = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb' + +describe('BIP85: Child Entropy', () => { + it('works for test case 1', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.derive(`m/83696968'/0'/0'`) + + expect(child).toEqual( + 'efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7' + ) + }) + + it('works for test case 2', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.derive(`m/83696968'/0'/1'`) + + expect(child).toEqual( + '70c6e3e8ebee8dc4c0dbba66076819bb8c09672527c4277ca8729532ad711872218f826919f6b67218adde99018a6df9095ab2b58d803b5b93ec9802085a690e' + ) + }) + + it('works for BIP39, 12 words', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveBIP39(0, 12, 0) + + expect(child.toEntropy()).toEqual('6250b68daf746d12a24d58b4787a714b') + expect(child.toMnemonic()).toEqual('girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose') + }) + + it('works for BIP39, 18 words', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveBIP39(0, 18, 0) + + expect(child.toEntropy()).toEqual('938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc') + expect(child.toMnemonic()).toEqual( + 'near account window bike charge season chef number sketch tomorrow excuse sniff circle vital hockey outdoor supply token' + ) + }) + + it('works for BIP39, 24 words', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveBIP39(0, 24, 0) + + expect(child.toEntropy()).toEqual('ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f') + expect(child.toMnemonic()).toEqual( + 'puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano' + ) + }) + + it('works for HD-Seed WIF', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveWIF(0) + + expect(child.toEntropy()).toEqual('7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1') + expect(child.toWIF()).toEqual('Kzyv4uF39d4Jrw2W7UryTHwZr1zQVNk4dAFyqE6BuMrMh1Za7uhp') + }) + + it('works for XPRV', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveXPRV(0) + + expect(child.toEntropy()).toEqual('ead0b33988a616cf6a497f1c169d9e92562604e38305ccd3fc96f2252c177682') + expect(child.toXPRV()).toEqual( + 'xprv9s21ZrQH143K2srSbCSg4m4kLvPMzcWydgmKEnMmoZUurYuBuYG46c6P71UGXMzmriLzCCBvKQWBUv3vPB3m1SATMhp3uEjXHJ42jFg7myX' + ) + }) + + it('works for HEX', () => { + const master = BIP85.fromBase58(rootKey) + const child = master.deriveHex(64, 0) + + expect(child.toEntropy()).toEqual( + '492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c' + ) + }) +}) diff --git a/src/app/models/BIP85.ts b/src/app/models/BIP85.ts new file mode 100644 index 00000000..f9db9dad --- /dev/null +++ b/src/app/models/BIP85.ts @@ -0,0 +1,162 @@ +import { BIP32Interface, fromBase58, fromSeed } from 'bip32' +import { validateMnemonic, entropyToMnemonic, mnemonicToSeed } from 'bip39' +import { BIP85Child } from './BIP85Child' + +import * as createHmac from 'create-hmac' + +export function checkValidIndex(index: number): boolean { + return typeof index === 'number' && index >= 0 +} + +// Copied from https://github.com/bitcoinjs/bip32/blob/master/ts-src/crypto.ts because it is not exported +export function hmacSHA512(key: Buffer, data: Buffer): Buffer { + return createHmac('sha512', key).update(data).digest() +} + +// https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki + +/** + * Constants defined in BIP-85 + */ +const BIP85_KEY: string = 'bip-entropy-from-k' +const BIP85_DERIVATION_PATH: number = 83696968 +export enum BIP85_APPLICATIONS { + BIP39 = 39, + WIF = 2, + XPRV = 32, + HEX = 128169 +} + +/** + * BIP-85 helper types + */ +type BIP85_WORD_LENGTHS = 12 | 18 | 24 + +type BIP39_LANGUAGES = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 + +/** + * Derive BIP-39 child entropy from a BIP-32 root key + */ +export class BIP85 { + private node: BIP32Interface + + constructor(node: BIP32Interface) { + this.node = node + } + + deriveBIP39(language: BIP39_LANGUAGES, words: BIP85_WORD_LENGTHS, index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('BIP39 invalid index') + } + + if (typeof language !== 'number') { + throw new Error('BIP39 invalid language type') + } + + if (!(language >= 0 && language <= 8)) { + throw new Error('BIP39 invalid language') + } + + const entropyLength: 16 | 24 | 32 = ((): 16 | 24 | 32 => { + switch (words) { + case 12: + return 16 + case 18: + return 24 + case 24: + return 32 + + default: + throw new Error('BIP39 invalid mnemonic length') + } + })() + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.BIP39}'/${language}'/${words}'/${index}'`, entropyLength) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.BIP39) + } + + deriveWIF(index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('WIF invalid index') + } + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.WIF}'/${index}'`, 32) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.WIF) + } + + deriveXPRV(index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('XPRV invalid index') + } + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.XPRV}'/${index}'`, 64) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.XPRV) + } + + deriveHex(numBytes: number, index: number = 0): BIP85Child { + if (!checkValidIndex(index)) { + throw new Error('HEX invalid index') + } + + if (typeof numBytes !== 'number') { + throw new Error('HEX invalid byte length type') + } + + if (numBytes < 16 || numBytes > 64) { + throw new Error('HEX invalid byte length') + } + + const entropy = this.derive(`m/${BIP85_DERIVATION_PATH}'/${BIP85_APPLICATIONS.HEX}'/${numBytes}'/${index}'`, numBytes) + + return new BIP85Child(entropy, BIP85_APPLICATIONS.HEX) + } + + derive(path: string, bytesLength: number = 64): string { + const childNode: BIP32Interface = this.node.derivePath(path) + const childPrivateKey: Buffer = childNode.privateKey! // Child derived from root key always has private key + + const hash: Buffer = hmacSHA512(Buffer.from(BIP85_KEY), childPrivateKey) + const truncatedHash: Buffer = hash.slice(0, bytesLength) + + const childEntropy: string = truncatedHash.toString('hex') + + return childEntropy + } + + static fromBase58(bip32seed: string): BIP85 { + const node: BIP32Interface = fromBase58(bip32seed) + if (node.depth !== 0) { + throw new Error('Expected master, got child') + } + + return new BIP85(node) + } + + static fromSeed(bip32seed: Buffer): BIP85 { + const node: BIP32Interface = fromSeed(bip32seed) + if (node.depth !== 0) { + throw new Error('Expected master, got child') + } + + return new BIP85(node) + } + + static fromEntropy(entropy: string, password: string = ''): BIP85 { + const mnemonic = entropyToMnemonic(entropy) + + return BIP85.fromMnemonic(mnemonic, password) + } + + static fromMnemonic(mnemonic: string, password: string = ''): BIP85 { + if (!validateMnemonic(mnemonic)) { + throw new Error('Invalid mnemonic') + } + + const seed = mnemonicToSeed(mnemonic, password) + + return BIP85.fromSeed(seed) + } +} diff --git a/src/app/models/BIP85Child.ts b/src/app/models/BIP85Child.ts new file mode 100644 index 00000000..7767e38b --- /dev/null +++ b/src/app/models/BIP85Child.ts @@ -0,0 +1,45 @@ +import { encode } from 'wif' +import { fromPrivateKey } from 'bip32' +import { entropyToMnemonic } from 'bip39' +import { BIP85_APPLICATIONS } from './BIP85' + +export class BIP85Child { + constructor(private readonly entropy: string, private readonly type: BIP85_APPLICATIONS) {} + + toEntropy(): string { + if (this.type === BIP85_APPLICATIONS.XPRV) { + return this.entropy.slice(64, 128) + } else { + return this.entropy + } + } + + toMnemonic(): string { + if (this.type !== BIP85_APPLICATIONS.BIP39) { + throw new Error('BIP85Child type is not BIP39') + } + + return entropyToMnemonic(this.entropy) + } + + toWIF(): string { + if (this.type !== BIP85_APPLICATIONS.WIF) { + throw new Error('BIP85Child type is not WIF') + } + + const buf = Buffer.from(this.entropy, 'hex') + + return encode(128, buf, true) + } + + toXPRV(): string { + if (this.type !== BIP85_APPLICATIONS.XPRV) { + throw new Error('BIP85Child type is not XPRV') + } + + const chainCode = Buffer.from(this.entropy.slice(0, 64), 'hex') + const privateKey = Buffer.from(this.entropy.slice(64, 128), 'hex') + + return fromPrivateKey(privateKey, chainCode).toBase58() + } +} diff --git a/src/app/pages/bip85-generate/bip85-generate-routing.module.ts b/src/app/pages/bip85-generate/bip85-generate-routing.module.ts new file mode 100644 index 00000000..00c6d30d --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +import { Bip85GeneratePage } from './bip85-generate.page' + +const routes: Routes = [ + { + path: '', + component: Bip85GeneratePage + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class Bip85GeneratePageRoutingModule {} diff --git a/src/app/pages/bip85-generate/bip85-generate.module.ts b/src/app/pages/bip85-generate/bip85-generate.module.ts new file mode 100644 index 00000000..4aa0ac7b --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' + +import { IonicModule } from '@ionic/angular' + +import { Bip85GeneratePageRoutingModule } from './bip85-generate-routing.module' + +import { Bip85GeneratePage } from './bip85-generate.page' +import { TranslateModule } from '@ngx-translate/core' + +@NgModule({ + imports: [CommonModule, FormsModule, IonicModule, Bip85GeneratePageRoutingModule, TranslateModule], + declarations: [Bip85GeneratePage] +}) +export class Bip85GeneratePageModule {} diff --git a/src/app/pages/bip85-generate/bip85-generate.page.html b/src/app/pages/bip85-generate/bip85-generate.page.html new file mode 100644 index 00000000..020ecc32 --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.page.html @@ -0,0 +1,64 @@ + + + + + + {{ 'bip85-generate.title' | translate }} + + + + +

{{ 'bip85-generate.text' | translate }}

+ + + + {{ 'bip85-generate.mnemonic-length' | translate }} + + 12 + 18 + 24 + + + + + {{ 'bip85-generate.index' | translate }} + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + + + + + {{ 'bip85-generate.advanced_label' | translate }} + + + + + + {{ 'bip85-generate.bip39-passphrase' | translate }} + + + + {{ 'bip85-generate.bip39-passphrase-reveal' | translate }} + + + + + + + {{ 'bip85-generate.generate' | translate }} + +
diff --git a/src/app/pages/bip85-generate/bip85-generate.page.scss b/src/app/pages/bip85-generate/bip85-generate.page.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/bip85-generate/bip85-generate.page.spec.ts b/src/app/pages/bip85-generate/bip85-generate.page.spec.ts new file mode 100644 index 00000000..c7451dca --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.page.spec.ts @@ -0,0 +1,24 @@ +// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +// import { IonicModule } from '@ionic/angular'; + +// import { Bip85GeneratePage } from './bip85-generate.page'; + +// describe('Bip85GeneratePage', () => { +// let component: Bip85GeneratePage; +// let fixture: ComponentFixture; + +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [ Bip85GeneratePage ], +// imports: [IonicModule.forRoot()] +// }).compileComponents(); + +// fixture = TestBed.createComponent(Bip85GeneratePage); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// })); + +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/src/app/pages/bip85-generate/bip85-generate.page.ts b/src/app/pages/bip85-generate/bip85-generate.page.ts new file mode 100644 index 00000000..28f3b050 --- /dev/null +++ b/src/app/pages/bip85-generate/bip85-generate.page.ts @@ -0,0 +1,81 @@ +import { Component } from '@angular/core' +import { AlertController } from '@ionic/angular' +import { TranslateService } from '@ngx-translate/core' +import { Secret } from 'src/app/models/secret' +import { ErrorCategory, handleErrorLocal } from 'src/app/services/error-handler/error-handler.service' +import { NavigationService } from 'src/app/services/navigation/navigation.service' + +@Component({ + selector: 'airgap-bip85-generate', + templateUrl: './bip85-generate.page.html', + styleUrls: ['./bip85-generate.page.scss'] +}) +export class Bip85GeneratePage { + public secret: Secret + + public mnemonicLength: '12' | '18' | '24' = '24' + + public index: string = '0' + + public isAdvancedMode: boolean = false + public revealBip39Passphrase: boolean = false + public bip39Passphrase: string = '' + + constructor( + private readonly navigationService: NavigationService, + private readonly alertController: AlertController, + private readonly translateService: TranslateService + ) { + if (this.navigationService.getState()) { + this.secret = this.navigationService.getState().secret + console.log(this.secret) + } + } + + public async generateChildSeed() { + if (this.bip39Passphrase.length > 0 && this.isAdvancedMode) { + const alert = await this.alertController.create({ + header: this.translateService.instant('bip85-generate.alert.header'), + message: this.translateService.instant('bip85-generate.alert.message'), + backdropDismiss: false, + inputs: [ + { + name: 'understood', + type: 'checkbox', + label: this.translateService.instant('bip85-generate.alert.understand'), + value: 'understood', + checked: false + } + ], + buttons: [ + { + text: 'Cancel', + role: 'cancel' + }, + { + text: 'Ok', + handler: async (result: string[]) => { + if (result.includes('understood')) { + this.navigateToNextPage() + } + } + } + ] + }) + alert.present() + } else { + this.navigateToNextPage() + } + } + + private async navigateToNextPage() { + this.navigationService + .routeWithState('/bip85-show', { + secret: this.secret, + bip39Passphrase: this.isAdvancedMode ? this.bip39Passphrase : '', + mnemonicLength: Number(this.mnemonicLength), + index: Number(this.index) + }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } +} diff --git a/src/app/pages/bip85-show/bip85-show-routing.module.ts b/src/app/pages/bip85-show/bip85-show-routing.module.ts new file mode 100644 index 00000000..3cf2292c --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +import { Bip85ShowPage } from './bip85-show.page' + +const routes: Routes = [ + { + path: '', + component: Bip85ShowPage + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class Bip85ShowPageRoutingModule {} diff --git a/src/app/pages/bip85-show/bip85-show.module.ts b/src/app/pages/bip85-show/bip85-show.module.ts new file mode 100644 index 00000000..4d4c585c --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' + +import { IonicModule } from '@ionic/angular' + +import { Bip85ShowPageRoutingModule } from './bip85-show-routing.module' + +import { Bip85ShowPage } from './bip85-show.page' +import { TranslateModule } from '@ngx-translate/core' + +@NgModule({ + imports: [CommonModule, FormsModule, IonicModule, Bip85ShowPageRoutingModule, TranslateModule], + declarations: [Bip85ShowPage] +}) +export class Bip85ShowPageModule {} diff --git a/src/app/pages/bip85-show/bip85-show.page.html b/src/app/pages/bip85-show/bip85-show.page.html new file mode 100644 index 00000000..2e896e0e --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.page.html @@ -0,0 +1,21 @@ + + + + + + {{ 'bip85-show.title' | translate }} + + + + +

{{ 'bip85-show.text' | translate }}

+

{{ 'bip85-show.mnemonic-length' | translate }}

+

{{mnemonicLength}}

+

{{ 'bip85-show.index' | translate }}

+

{{index}}

+
{{ childMnemonic }}
+ + + {{ 'bip85-show.validate' | translate }} + +
diff --git a/src/app/pages/bip85-show/bip85-show.page.scss b/src/app/pages/bip85-show/bip85-show.page.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/bip85-show/bip85-show.page.spec.ts b/src/app/pages/bip85-show/bip85-show.page.spec.ts new file mode 100644 index 00000000..7522b455 --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.page.spec.ts @@ -0,0 +1,24 @@ +// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +// import { IonicModule } from '@ionic/angular'; + +// import { Bip85ShowPage } from './bip85-show.page'; + +// describe('Bip85ShowPage', () => { +// let component: Bip85ShowPage; +// let fixture: ComponentFixture; + +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [ Bip85ShowPage ], +// imports: [IonicModule.forRoot()] +// }).compileComponents(); + +// fixture = TestBed.createComponent(Bip85ShowPage); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// })); + +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/src/app/pages/bip85-show/bip85-show.page.ts b/src/app/pages/bip85-show/bip85-show.page.ts new file mode 100644 index 00000000..617339a0 --- /dev/null +++ b/src/app/pages/bip85-show/bip85-show.page.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core' +import { BIP85 } from 'src/app/models/BIP85' +import { Secret } from 'src/app/models/secret' +import { DeviceService } from 'src/app/services/device/device.service' +import { ErrorCategory, handleErrorLocal } from 'src/app/services/error-handler/error-handler.service' +import { NavigationService } from 'src/app/services/navigation/navigation.service' +import { SecureStorage, SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' + +@Component({ + selector: 'airgap-bip85-show', + templateUrl: './bip85-show.page.html', + styleUrls: ['./bip85-show.page.scss'] +}) +export class Bip85ShowPage { + private secret: Secret + public mnemonicLength: 12 | 18 | 24 + public index: number + + public childMnemonic: string | undefined + + public bip39Passphrase: string = '' + + constructor( + private readonly deviceService: DeviceService, + private readonly navigationService: NavigationService, + private readonly secureStorageService: SecureStorageService + ) { + if (this.navigationService.getState()) { + this.secret = this.navigationService.getState().secret + this.mnemonicLength = this.navigationService.getState().mnemonicLength + this.index = this.navigationService.getState().index + this.bip39Passphrase = this.navigationService.getState().bip39Passphrase + + this.generateChildMnemonic(this.secret, this.mnemonicLength, this.index) + } + } + + public ionViewDidEnter(): void { + this.deviceService.enableScreenshotProtection({ routeBack: 'tab-settings' }) + } + + public ionViewWillLeave(): void { + this.deviceService.disableScreenshotProtection() + } + + public goToValidateSecret(): void { + this.navigationService + .routeWithState('bip85-validate', { + secret: this.secret, + bip39Passphrase: this.bip39Passphrase, + mnemonicLength: this.mnemonicLength, + index: this.index + }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } + + private async generateChildMnemonic(secret: Secret, length: 12 | 18 | 24, index: number) { + const secureStorage: SecureStorage = await this.secureStorageService.get(secret.id, secret.isParanoia) + + try { + const secretHex = await secureStorage.getItem(secret.id).then((result) => result.value) + + const masterSeed = BIP85.fromEntropy(secretHex, this.bip39Passphrase) + + const childEntropy = masterSeed.deriveBIP39(0, length, index) + + this.childMnemonic = childEntropy.toMnemonic() + } catch (error) { + throw error + } + } +} diff --git a/src/app/pages/bip85-validate/bip85-validate-routing.module.ts b/src/app/pages/bip85-validate/bip85-validate-routing.module.ts new file mode 100644 index 00000000..f732b21c --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +import { Bip85ValidatePage } from './bip85-validate.page' + +const routes: Routes = [ + { + path: '', + component: Bip85ValidatePage + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class Bip85ValidatePageRoutingModule {} diff --git a/src/app/pages/bip85-validate/bip85-validate.module.ts b/src/app/pages/bip85-validate/bip85-validate.module.ts new file mode 100644 index 00000000..41c456e3 --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' + +import { IonicModule } from '@ionic/angular' + +import { Bip85ValidatePageRoutingModule } from './bip85-validate-routing.module' + +import { Bip85ValidatePage } from './bip85-validate.page' +import { TranslateModule } from '@ngx-translate/core' +import { ComponentsModule } from 'src/app/components/components.module' + +@NgModule({ + imports: [CommonModule, FormsModule, IonicModule, Bip85ValidatePageRoutingModule, TranslateModule, ComponentsModule], + declarations: [Bip85ValidatePage] +}) +export class Bip85ValidatePageModule {} diff --git a/src/app/pages/bip85-validate/bip85-validate.page.html b/src/app/pages/bip85-validate/bip85-validate.page.html new file mode 100644 index 00000000..a3c66f1e --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.page.html @@ -0,0 +1,15 @@ + + + + + + {{ 'bip85-validate.title' | translate }} + + + + + +

{{ 'bip85-validate.text' | translate }}

+ +
+
diff --git a/src/app/pages/bip85-validate/bip85-validate.page.scss b/src/app/pages/bip85-validate/bip85-validate.page.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/bip85-validate/bip85-validate.page.spec.ts b/src/app/pages/bip85-validate/bip85-validate.page.spec.ts new file mode 100644 index 00000000..f148e476 --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.page.spec.ts @@ -0,0 +1,24 @@ +// import { async, ComponentFixture, TestBed } from '@angular/core/testing' +// import { IonicModule } from '@ionic/angular' + +// import { Bip85ValidatePage } from './bip85-validate.page' + +// describe('Bip85ValidatePage', () => { +// let component: Bip85ValidatePage +// let fixture: ComponentFixture + +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [Bip85ValidatePage], +// imports: [IonicModule.forRoot()] +// }).compileComponents() + +// fixture = TestBed.createComponent(Bip85ValidatePage) +// component = fixture.componentInstance +// fixture.detectChanges() +// })) + +// it('should create', () => { +// expect(component).toBeTruthy() +// }) +// }) diff --git a/src/app/pages/bip85-validate/bip85-validate.page.ts b/src/app/pages/bip85-validate/bip85-validate.page.ts new file mode 100644 index 00000000..8a2a9dca --- /dev/null +++ b/src/app/pages/bip85-validate/bip85-validate.page.ts @@ -0,0 +1,75 @@ +import { Component, ViewChild } from '@angular/core' +import { BIP85 } from 'src/app/models/BIP85' +import { VerifyKeyComponent } from 'src/app/components/verify-key/verify-key.component' +import { Secret } from 'src/app/models/secret' +import { DeviceService } from 'src/app/services/device/device.service' +import { ErrorCategory, handleErrorLocal } from 'src/app/services/error-handler/error-handler.service' +import { NavigationService } from 'src/app/services/navigation/navigation.service' +import { SecureStorage, SecureStorageService } from 'src/app/services/secure-storage/secure-storage.service' + +@Component({ + selector: 'airgap-bip85-validate', + templateUrl: './bip85-validate.page.html', + styleUrls: ['./bip85-validate.page.scss'] +}) +export class Bip85ValidatePage { + @ViewChild('verify', { static: true }) + public verify: VerifyKeyComponent + + public readonly secret: Secret + public mnemonicLength: 12 | 18 | 24 + public index: number + + public childMnemonic: string | undefined + + public bip39Passphrase: string = '' + + constructor( + private readonly deviceService: DeviceService, + private readonly navigationService: NavigationService, + private readonly secureStorageService: SecureStorageService + ) { + if (this.navigationService.getState()) { + this.secret = this.navigationService.getState().secret + this.mnemonicLength = this.navigationService.getState().mnemonicLength + this.index = this.navigationService.getState().index + this.bip39Passphrase = this.navigationService.getState().bip39Passphrase + + this.generateChildMnemonic(this.secret, this.mnemonicLength, this.index) + } + } + + public ionViewDidEnter(): void { + this.deviceService.enableScreenshotProtection({ routeBack: 'tab-settings' }) + } + + public ionViewWillLeave(): void { + this.deviceService.disableScreenshotProtection() + } + + public onContinue(): void { + this.goToSecretEditPage() + } + + public goToSecretEditPage(): void { + this.navigationService + .routeWithState('secret-edit', { secret: this.secret, isGenerating: false }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } + + private async generateChildMnemonic(secret: Secret, length: 12 | 18 | 24, index: number) { + const secureStorage: SecureStorage = await this.secureStorageService.get(secret.id, secret.isParanoia) + + try { + const secretHex = await secureStorage.getItem(secret.id).then((result) => result.value) + + const masterSeed = BIP85.fromEntropy(secretHex, this.bip39Passphrase) + + const childEntropy = masterSeed.deriveBIP39(0, length, index) + + this.childMnemonic = childEntropy.toMnemonic() + } catch (error) { + throw error + } + } +} diff --git a/src/app/pages/deserialized-detail/deserialized-detail.actions.ts b/src/app/pages/deserialized-detail/deserialized-detail.actions.ts new file mode 100644 index 00000000..ec52b20b --- /dev/null +++ b/src/app/pages/deserialized-detail/deserialized-detail.actions.ts @@ -0,0 +1,81 @@ +import { ProtocolSymbols } from '@airgap/coinlib-core' +import { createAction, props } from '@ngrx/store' + +// tslint:disable: typedef +import { + DeserializedMessage, + DeserializedSignedMessage, + DeserializedSignedTransaction, + DeserializedTransaction, + Task, + Mode +} from './deserialized.detail.types' + +// FIXME [#210]: remove once the performance issue is resolved +export interface UserInput { + bip39Passphrase?: string + protocol?: ProtocolSymbols +} +// [#210] + +const featureName = 'Deserialized Detail' + +/**************** View Lifecycle ****************/ + +export const viewInitialization = createAction(`[${featureName}] View Initialization`) + +/**************** Navigation Data ****************/ + +export const navigationDataLoading = createAction(`[${featureName}] Navigation Data Loading`) +export const navigationDataLoaded = createAction( + `[${featureName}] Navigation Data Loaded`, + props<{ + mode: Mode + title: string + button: string | undefined + transactions: DeserializedTransaction[] + messages: DeserializedMessage[] + raw: string + }>() +) +export const navigationDataLoadingError = createAction( + `[${featureName}] Navigation Data Loading Error`, + props<{ mode: Mode; title: string; button: string | undefined; raw: string }>() +) +export const invalidData = createAction(`[${featureName}] Invalid Navigation Data`) + +/**************** User Interaction ****************/ + +export const approved = createAction(`[${featureName}] Approved`) + +// FIXME [#210]: remove once the performance issue is resolved +export const continueApproved = createAction(`[${featureName}] Continue Approved`, props<{ userInput: UserInput }>()) +// [#210] + +export const alertDismissed = createAction(`[${featureName}] Alert Dismissed`, props<{ id: string }>()) +export const modalDismissed = createAction(`[${featureName}] Modal Dismissed`, props<{ id: string }>()) + +export const bip39PassphraseProvided = createAction(`[${featureName}] BIP-39 Passphrase Provided`, props<{ passphrase: string }>()) +export const signingProtocolProvided = createAction(`[${featureName}] Signing Protocol Provided`, props<{ protocol: ProtocolSymbols }>()) + +/**************** Internal ****************/ + +// export const runningBlockingTask = createAction(`[${featureName}] Running Blocking Task`, props<{ task: Task }>()) + +// FIXME [#210] replace with the above once the performance issue is resolved +export const runningBlockingTask = createAction(`[${featureName}] Running Blocking Task`, props<{ task: Task; userInput: UserInput }>()) +// [#210] + +export const transactionsSigned = createAction( + `[${featureName}] Transactions Signed`, + props<{ transactions: DeserializedSignedTransaction[] }>() +) +export const messagesSigned = createAction(`[${featureName}] Messages Signed`, props<{ messages: DeserializedSignedMessage[] }>()) + +export const loaderDismissed = createAction(`[${featureName}] Loader Dismissed`, props<{ id: string }>()) + +export const secretNotFound = createAction(`[${featureName}] Secret Not Found`) +export const protocolNotFound = createAction(`[${featureName}] Protocol Not Found`) +export const invalidBip39Passphrase = createAction(`[${featureName}] Invalid BIP-39 Passphrase`) + +export const unknownError = createAction(`[${featureName}] Unknown Error`, props<{ message?: string }>()) diff --git a/src/app/pages/deserialized-detail/deserialized-detail.effects.ts b/src/app/pages/deserialized-detail/deserialized-detail.effects.ts new file mode 100644 index 00000000..23c3c842 --- /dev/null +++ b/src/app/pages/deserialized-detail/deserialized-detail.effects.ts @@ -0,0 +1,535 @@ +import { + assertNever, + flattenAirGapTxAddresses, + KeyPairService, + ProtocolService, + SerializerService, + sumAirGapTxValues, + TransactionService +} from '@airgap/angular-core' +import { + AirGapWallet, + IACMessageDefinitionObject, + IACMessageType, + IAirGapTransaction, + ICoinProtocol, + MainProtocolSymbols, + MessageSignRequest, + ProtocolSymbols, + SignedTransaction, + TezosCryptoClient, + TezosSaplingProtocol, + UnsignedTransaction +} from '@airgap/coinlib-core' +import { Injectable } from '@angular/core' +import { Actions, createEffect, ofType } from '@ngrx/effects' +import { Action, Store } from '@ngrx/store' +import { TranslateService } from '@ngx-translate/core' +import * as bip39 from 'bip39' +import { from, Observable } from 'rxjs' +import { concatMap, first, switchMap, tap, withLatestFrom } from 'rxjs/operators' + +import { Secret } from '../../models/secret' +import { SignTransactionInfo } from '../../models/sign-transaction-info' +import { InteractionOperationType, InteractionService } from '../../services/interaction/interaction.service' +import { NavigationService } from '../../services/navigation/navigation.service' +import { SecretsService } from '../../services/secrets/secrets.service' + +import * as actions from './deserialized-detail.actions' +import * as fromDeserializedDetail from './deserialized-detail.reducer' +import { + DeserializedSignedMessage, + DeserializedSignedTransaction, + DeserializedUnsignedMessage, + DeserializedUnsignedTransaction, + Mode, + Payload, + Task +} from './deserialized.detail.types' + +@Injectable() +export class DeserializedDetailEffects { + public navigationData$ = createEffect(() => + this.actions$.pipe( + ofType(actions.viewInitialization), + switchMap( + () => + new Observable((subscriber) => { + subscriber.next(actions.navigationDataLoading()) + from(this.loadNavigationData()).pipe(first()).subscribe(subscriber) + }) + ) + ) + ) + + // FIXME [#210]: + // We can no longer execute the signing step as a single action due to Sapling heavy computational transaction signing + // https://gitlab.papers.tech/papers/airgap/airgap-vault/-/issues/210 + + // public approved$ = createEffect(() => + // this.actions$.pipe( + // ofType(actions.approved, actions.bip39PassphraseProvided, actions.signingProtocolProvided), + // concatMap((action) => from([action]).pipe(withLatestFrom(this.store.select(fromDeserializedDetail.selectFinalPayload)))), + // switchMap(([action, payload]) => { + // const userInput = { + // bip39Passphrase: 'passphrase' in action ? action.passphrase : undefined, + // protocol: 'protocol' in action ? action.protocol : undefined + // } + + // return new Observable((subscriber) => { + // subscriber.next(actions.runningBlockingTask({ task: this.identifyPayloadTask(payload) })) + // from(this.handlePayload(payload, userInput)).pipe(first()).subscribe(subscriber) + // }) + // }) + // ) + // ) + // [#210] + + // FIXME [#210]: replace with the above once the performance issue is resolved + public approved$ = createEffect(() => + this.actions$.pipe( + ofType(actions.approved, actions.bip39PassphraseProvided, actions.signingProtocolProvided), + concatMap((action) => from([action]).pipe(withLatestFrom(this.store.select(fromDeserializedDetail.selectFinalPayload)))), + switchMap(([action, payload]) => { + const userInput = { + bip39Passphrase: 'passphrase' in action ? action.passphrase : undefined, + protocol: 'protocol' in action ? action.protocol : undefined + } + + return new Observable((subscriber) => { + subscriber.next(actions.runningBlockingTask({ task: this.identifyPayloadTask(payload), userInput })) + }) + }) + ) + ) + // [#210] + + // FIXME [#210]: remove once the performance issue is resolved + public continueApproved$ = createEffect(() => + this.actions$.pipe( + ofType(actions.continueApproved), + concatMap((action) => from([action]).pipe(withLatestFrom(this.store.select(fromDeserializedDetail.selectFinalPayload)))), + switchMap(([action, payload]) => { + return new Observable((subscriber) => { + from(this.handlePayload(payload, action.userInput)).pipe(first()).subscribe(subscriber) + }) + }) + ) + ) + // [#210] + + public navigateWithSignedTransactions$ = createEffect( + () => + this.actions$.pipe( + ofType(actions.transactionsSigned), + tap((action) => this.navigateWithSignedTransactions(action.transactions)) + ), + { dispatch: false } + ) + + public navigateWithSignedMessages$ = createEffect( + () => + this.actions$.pipe( + ofType(actions.messagesSigned), + tap((action) => this.navigateWithSignedMessages(action.messages)) + ), + { dispatch: false } + ) + + constructor( + private readonly actions$: Actions, + private readonly store: Store, + private readonly navigationService: NavigationService, + private readonly translateService: TranslateService, + private readonly protocolService: ProtocolService, + private readonly secretsService: SecretsService, + private readonly keyPairService: KeyPairService, + private readonly transactionService: TransactionService, + private readonly serializerService: SerializerService, + private readonly interactionService: InteractionService + ) {} + + private async loadNavigationData(): Promise { + const state = this.navigationService.getState() + const [, messageType]: [string | undefined, string | undefined] = this.getPageTypes(state.type) + const mode: Mode | undefined = this.getMode(state.type) + + if (messageType !== undefined && mode !== undefined && state.transactionInfos !== undefined) { + const title: string = this.translateService.instant(`deserialized-detail.${messageType}.title`) + const button: string = this.translateService.instant(`deserialized-detail.${messageType}.button_label`) + const raw: string = this.signTransactionInfoToRaw(state.transactionInfos) + try { + const [transactions, messages]: [DeserializedUnsignedTransaction[], DeserializedUnsignedMessage[]] = await Promise.all([ + this.signTransactionInfoToUnsignedTransactions(state.transactionInfos), + this.signTransactionInfoToUnsignedMessages(state.transactionInfos) + ]) + + return actions.navigationDataLoaded({ mode, title, button, transactions, messages, raw }) + } catch (error) { + // tslint:disable-next-line: no-console + console.warn(error) + + return actions.navigationDataLoadingError({ mode, title, button, raw }) + } + } else { + return actions.invalidData() + } + } + + private getPageTypes(iacMessageType: IACMessageType | undefined): [string | undefined, string | undefined] { + let actionType: string | undefined + if (iacMessageType === IACMessageType.TransactionSignRequest || iacMessageType === IACMessageType.MessageSignRequest) { + actionType = 'request' + } else if (iacMessageType === IACMessageType.TransactionSignResponse || iacMessageType === IACMessageType.MessageSignResponse) { + actionType = 'response' + } + + let messageType: string | undefined + if (iacMessageType === IACMessageType.TransactionSignRequest || iacMessageType === IACMessageType.TransactionSignResponse) { + messageType = 'transaction' + } else if (iacMessageType === IACMessageType.MessageSignRequest || iacMessageType === IACMessageType.MessageSignResponse) { + messageType = 'message' + } + + return [actionType, messageType] + } + + private getMode(iacMessageType: IACMessageType | undefined): Mode | undefined { + switch (iacMessageType) { + case IACMessageType.TransactionSignRequest: + return Mode.SIGN_TRANSACTION + case IACMessageType.MessageSignRequest: + return Mode.SIGN_MESSAGE + default: + return undefined + } + } + + private signTransactionInfoToRaw(transactionInfo: SignTransactionInfo[]): string { + const transactions: unknown[] = transactionInfo.map((info: SignTransactionInfo): unknown => { + switch (info.signTransactionRequest.type) { + case IACMessageType.TransactionSignRequest: + return (info.signTransactionRequest.payload as UnsignedTransaction).transaction + case IACMessageType.TransactionSignResponse: + return (info.signTransactionRequest.payload as SignedTransaction).transaction + default: + return info.signTransactionRequest.payload + } + }) + const toStringify: unknown | unknown[] = transactions.length > 1 ? transactions : transactions[0] + + return JSON.stringify(toStringify) + } + + private async signTransactionInfoToUnsignedTransactions( + transactionInfo: SignTransactionInfo[] + ): Promise { + return Promise.all( + transactionInfo + .map((info: SignTransactionInfo): [AirGapWallet, IACMessageDefinitionObject] => [info.wallet, info.signTransactionRequest]) + .filter( + ([_, request]: [AirGapWallet, IACMessageDefinitionObject]): boolean => request.type === IACMessageType.TransactionSignRequest + ) + .map( + async ([wallet, request]: [AirGapWallet, IACMessageDefinitionObject]): Promise => { + let details: IAirGapTransaction[] + if (await this.checkIfSaplingTransaction(request.payload as UnsignedTransaction, request.protocol)) { + details = await this.transactionService.getDetailsFromIACMessages([request], { + overrideProtocol: await this.getSaplingProtocol(), + data: { + knownViewingKeys: this.secretsService.getKnownViewingKeys() + } + }) + } else { + details = await this.transactionService.getDetailsFromIACMessages([request]) + } + + return { + type: 'unsigned', + id: request.id, + details, + data: request.payload as UnsignedTransaction, + wallet + } + } + ) + ) + } + + private async signTransactionInfoToUnsignedMessages(transactionInfo: SignTransactionInfo[]): Promise { + return Promise.all( + transactionInfo + .map((info: SignTransactionInfo): [AirGapWallet, IACMessageDefinitionObject] => [info.wallet, info.signTransactionRequest]) + .filter(([_, request]: [AirGapWallet, IACMessageDefinitionObject]): boolean => request.type === IACMessageType.MessageSignRequest) + .map( + async ([wallet, request]: [AirGapWallet, IACMessageDefinitionObject]): Promise => { + const data: MessageSignRequest = request.payload as MessageSignRequest + + let blake2bHash: string | undefined + if (request.protocol === MainProtocolSymbols.XTZ) { + const cryptoClient = new TezosCryptoClient() + blake2bHash = await cryptoClient.blake2bLedgerHash(data.message) + } + + return { + type: 'unsigned', + id: request.id, + protocol: request.protocol, + data, + blake2bHash, + wallet + } + } + ) + ) + } + + private identifyPayloadTask(payload: Payload): Task { + switch (payload.mode) { + case Mode.SIGN_TRANSACTION: + return 'signTransaction' + case Mode.SIGN_MESSAGE: + return 'signMessage' + default: + return 'generic' + } + } + + private async handlePayload(payload: Payload, userInput: { bip39Passphrase?: string; protocol?: ProtocolSymbols }): Promise { + switch (payload.mode) { + case Mode.SIGN_TRANSACTION: + return this.signTransactions(payload.data, userInput.bip39Passphrase) + case Mode.SIGN_MESSAGE: + return this.signMessages(payload.data, userInput.bip39Passphrase, userInput.protocol) + default: + assertNever('handlePayload', payload) + } + } + + private async signTransactions(unsignedTransactions: DeserializedUnsignedTransaction[], bip39Passphrase: string = ''): Promise { + try { + const signedTransactions: DeserializedSignedTransaction[] = await Promise.all( + unsignedTransactions.map( + async (transaction: DeserializedUnsignedTransaction): Promise => { + const signed: string = await this.signTransaction(transaction.wallet, transaction.data, bip39Passphrase) + + return { + type: 'signed', + id: transaction.id, + details: transaction.details, + data: { + accountIdentifier: transaction.wallet.publicKey.substr(-6), + transaction: signed, + callbackURL: transaction.data.callbackURL + }, + wallet: transaction.wallet + } + } + ) + ) + + return actions.transactionsSigned({ transactions: signedTransactions }) + } catch (error) { + // tslint:disable-next-line: no-console + console.warn(error) + + if (error.message?.toLowerCase().startsWith('secret not found')) { + return actions.secretNotFound() + } else if (error.message?.toLowerCase().startsWith('invalid bip-39 passphrase')) { + return actions.invalidBip39Passphrase() + } + + return actions.unknownError({ message: typeof error === 'string' ? error : error.message }) + } + } + + private async signTransaction(wallet: AirGapWallet, transaction: UnsignedTransaction, bip39Passphrase: string): Promise { + const secret: Secret | undefined = this.secretsService.findByPublicKey(wallet.publicKey) + if (secret === undefined) { + throw new Error('Secret not found') + } + + const entropy: string = await this.secretsService.retrieveEntropyForSecret(secret) + const mnemonic: string = bip39.entropyToMnemonic(entropy) + + return this.keyPairService.signWithWallet(wallet, transaction, mnemonic, bip39Passphrase) + } + + private async signMessages( + unsignedMessages: DeserializedUnsignedMessage[], + bip39Passphrase: string = '', + protocolIdentifier?: ProtocolSymbols + ): Promise { + try { + const signedMessages: DeserializedSignedMessage[] = await Promise.all( + unsignedMessages.map( + async (message: DeserializedUnsignedMessage): Promise => { + const signature: string = await this.signMessage(message.data, bip39Passphrase, message.wallet, protocolIdentifier) + + return { + type: 'signed', + id: message.id, + protocol: message.protocol ?? protocolIdentifier, + data: { + message: message.data.message, + publicKey: message.data.publicKey, + signature, + callbackURL: message.data.callbackURL + }, + wallet: message.wallet + } + } + ) + ) + + return actions.messagesSigned({ messages: signedMessages }) + } catch (error) { + // tslint:disable-next-line: no-console + console.warn(error) + + if (error.message?.toLowerCase().startsWith('secret not found')) { + return actions.secretNotFound() + } else if (error.message?.toLowerCase().startsWith('protocol not found')) { + return actions.protocolNotFound() + } else if (error.message?.toLowerCase().startsWith('invalid bip-39 passphrase')) { + return actions.invalidBip39Passphrase() + } + + return actions.unknownError({ message: typeof error === 'string' ? error : error.message }) + } + } + + private async signMessage( + message: MessageSignRequest, + bip39Passphrase: string, + wallet?: AirGapWallet, + protocolIdentifier?: ProtocolSymbols + ): Promise { + const secret: Secret | undefined = + wallet !== undefined + ? this.secretsService.findByPublicKey(wallet.publicKey) ?? this.secretsService.getActiveSecret() + : this.secretsService.getActiveSecret() + + if (secret === undefined) { + throw new Error('Secret not found') + } + + const entropy: string = await this.secretsService.retrieveEntropyForSecret(secret) + const mnemonic: string = bip39.entropyToMnemonic(entropy) + + if (wallet !== undefined) { + return this.keyPairService.signWithWallet(wallet, message, mnemonic, bip39Passphrase) + } else { + let protocol: ICoinProtocol | undefined + try { + protocol = + protocolIdentifier !== undefined ? await this.protocolService.getProtocol(protocolIdentifier, undefined, false) : undefined + } catch (error) { + // tslint:disable-next-line: no-console + console.warn(error) + protocol = undefined + } + + if (protocol === undefined) { + throw new Error('Protocol not found') + } + + return this.keyPairService.signWithProtocol(protocol, message, mnemonic, bip39Passphrase, false, protocol.standardDerivationPath) + } + } + + private async navigateWithSignedTransactions(transactions: DeserializedSignedTransaction[]): Promise { + const broadcastUrl: string = await this.generateTransactionBroadcastUrl(transactions) + this.interactionService.startInteraction( + { + operationType: InteractionOperationType.TRANSACTION_BROADCAST, + url: broadcastUrl, + wallets: transactions.map((transaction: DeserializedSignedTransaction): AirGapWallet => transaction.wallet), + signedTxs: transactions.map((transaction: DeserializedSignedTransaction): string => transaction.data.transaction) + }, + this.secretsService.getActiveSecret() + ) + } + + private async generateTransactionBroadcastUrl(transactions: DeserializedSignedTransaction[]): Promise { + const signResponses: IACMessageDefinitionObject[] = transactions.map( + (transaction: DeserializedSignedTransaction): IACMessageDefinitionObject => ({ + id: transaction.id, + protocol: transaction.wallet.protocol.identifier, + type: IACMessageType.TransactionSignResponse, + payload: { + accountIdentifier: transaction.data.accountIdentifier, + transaction: transaction.data.transaction, + from: flattenAirGapTxAddresses(transaction.details, 'from'), + to: flattenAirGapTxAddresses(transaction.details, 'to'), + amount: sumAirGapTxValues(transaction.details, 'amount'), + fee: sumAirGapTxValues(transaction.details, 'fee') + } + }) + ) + + return this.generateBroadcastUrl(signResponses, transactions[0]?.data.callbackURL) + } + + private async navigateWithSignedMessages(messages: DeserializedSignedMessage[]): Promise { + const broadcastUrl: string = await this.generateMessageBroadcastUrl(messages) + this.interactionService.startInteraction( + { + operationType: InteractionOperationType.MESSAGE_SIGN_REQUEST, + url: broadcastUrl, + messageSignResponse: + messages[0] !== undefined + ? { + message: messages[0].data.message, + publicKey: messages[0].data.publicKey, + signature: messages[0].data.signature + } + : undefined + }, + this.secretsService.getActiveSecret() + ) + } + + private async generateMessageBroadcastUrl(messages: DeserializedSignedMessage[]): Promise { + const signResponses: IACMessageDefinitionObject[] = messages.map( + (message: DeserializedSignedMessage): IACMessageDefinitionObject => ({ + id: message.id, + protocol: message.protocol, + type: IACMessageType.MessageSignResponse, + payload: { + message: message.data.message, + publicKey: message.data.publicKey, + signature: message.data.signature + } + }) + ) + + return this.generateBroadcastUrl(signResponses, messages[0]?.data.callbackURL) + } + + private async generateBroadcastUrl(messages: IACMessageDefinitionObject[], callbackUrl?: string): Promise { + const serialized: string[] = await this.serializerService.serialize(messages) + + return `${callbackUrl || 'airgap-wallet://?d='}${serialized.join(',')}` + } + + private async checkIfSaplingTransaction(transaction: UnsignedTransaction, protocolIdentifier: ProtocolSymbols): Promise { + if (protocolIdentifier === MainProtocolSymbols.XTZ) { + const tezosProtocol: ICoinProtocol = await this.protocolService.getProtocol(protocolIdentifier) + const saplingProtocol: TezosSaplingProtocol = await this.getSaplingProtocol() + + const txDetails: IAirGapTransaction[] = await tezosProtocol.getTransactionDetails(transaction) + const recipients: string[] = txDetails + .map((details) => details.to) + .reduce((flatten: string[], next: string[]) => flatten.concat(next), []) + + return recipients.includes(saplingProtocol.options.config.contractAddress) + } + + return protocolIdentifier === MainProtocolSymbols.XTZ_SHIELDED + } + + private async getSaplingProtocol(): Promise { + return (await this.protocolService.getProtocol(MainProtocolSymbols.XTZ_SHIELDED)) as TezosSaplingProtocol + } +} diff --git a/src/app/pages/deserialized-detail/deserialized-detail.module.ts b/src/app/pages/deserialized-detail/deserialized-detail.module.ts index 77bf3599..ee9fc6da 100644 --- a/src/app/pages/deserialized-detail/deserialized-detail.module.ts +++ b/src/app/pages/deserialized-detail/deserialized-detail.module.ts @@ -3,11 +3,15 @@ import { NgModule } from '@angular/core' import { FormsModule } from '@angular/forms' import { RouterModule, Routes } from '@angular/router' import { IonicModule } from '@ionic/angular' +import { EffectsModule } from '@ngrx/effects' +import { StoreModule } from '@ngrx/store' import { TranslateModule } from '@ngx-translate/core' import { ComponentsModule } from '../../components/components.module' +import { DeserializedDetailEffects } from './deserialized-detail.effects' import { DeserializedDetailPage } from './deserialized-detail.page' +import * as fromDeserializedDetail from './deserialized-detail.reducer' const routes: Routes = [ { @@ -17,7 +21,16 @@ const routes: Routes = [ ] @NgModule({ - imports: [CommonModule, ComponentsModule, FormsModule, IonicModule, RouterModule.forChild(routes), TranslateModule], + imports: [ + CommonModule, + ComponentsModule, + FormsModule, + IonicModule, + RouterModule.forChild(routes), + TranslateModule, + StoreModule.forFeature('deserializedDetail', fromDeserializedDetail.reducer), + EffectsModule.forFeature([DeserializedDetailEffects]) + ], declarations: [DeserializedDetailPage] }) export class DeserializedDetailPageModule {} diff --git a/src/app/pages/deserialized-detail/deserialized-detail.page.html b/src/app/pages/deserialized-detail/deserialized-detail.page.html index 3ff9ee8f..d3c78ccc 100644 --- a/src/app/pages/deserialized-detail/deserialized-detail.page.html +++ b/src/app/pages/deserialized-detail/deserialized-detail.page.html @@ -3,16 +3,46 @@ - {{ title }} + {{ title$ | async }} - - + + + + + - + + + + + + {{ 'deserialized-detail.transaction.unreadable_warning' | translate }} + + + + +

{{ (rawData$ | async).value }}

+
+
+
+
+
+ + + + + + + + + + + {{ button }} + +
diff --git a/src/app/pages/deserialized-detail/deserialized-detail.page.scss b/src/app/pages/deserialized-detail/deserialized-detail.page.scss index e69de29b..f0eeed9e 100644 --- a/src/app/pages/deserialized-detail/deserialized-detail.page.scss +++ b/src/app/pages/deserialized-detail/deserialized-detail.page.scss @@ -0,0 +1,12 @@ +.warning-icon { + font-size: 4rem; +} + +.word-break__all { + word-break: break-all; +} + +.deserialized-content { + --margin-bottom: 32px; + margin-bottom: 32px; +} diff --git a/src/app/pages/deserialized-detail/deserialized-detail.page.ts b/src/app/pages/deserialized-detail/deserialized-detail.page.ts index 7e034067..e1831a41 100644 --- a/src/app/pages/deserialized-detail/deserialized-detail.page.ts +++ b/src/app/pages/deserialized-detail/deserialized-detail.page.ts @@ -1,37 +1,267 @@ +import { assertNever, UIAction, UIActionStatus, UiEventService, UIResource, UIResourceStatus } from '@airgap/angular-core' +import { IAirGapTransaction, ProtocolSymbols } from '@airgap/coinlib-core' import { Component } from '@angular/core' -import { IACMessageDefinitionObject, IACMessageType } from '@airgap/coinlib-core' -import { NavigationService } from '../../services/navigation/navigation.service' -import { SignTransactionInfo } from 'src/app/models/sign-transaction-info' -import { TranslateService } from '@ngx-translate/core' +import { ModalController } from '@ionic/angular' +import { AlertOptions, LoadingOptions, ModalOptions, OverlayEventDetail } from '@ionic/core' +import { Store } from '@ngrx/store' +import { Observable, Subject } from 'rxjs' +import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators' + +import { ErrorCategory, handleErrorLocal } from '../../services/error-handler/error-handler.service' +import { SelectAccountPage } from '../select-account/select-account.page' + +import * as actions from './deserialized-detail.actions' +import * as fromDeserializedDetail from './deserialized-detail.reducer' +import { Alert, Task, Modal, Mode, UnsignedMessage } from './deserialized.detail.types' + +type ModalOnDismissAction = (modalData: OverlayEventDetail) => Promise -// TODO: refactor multiple transactions @Component({ selector: 'airgap-deserialized-detail', templateUrl: './deserialized-detail.page.html', styleUrls: ['./deserialized-detail.page.scss'] }) export class DeserializedDetailPage { - public broadcastUrl?: string + public mode$: Observable + public title$: Observable + public button$: Observable + + public transactionsDetails$: Observable> + public messages$: Observable> + public rawData$: Observable> + + public loader$: Observable<(UIAction & { userInput: actions.UserInput }) | undefined> + public alert$: Observable | undefined> + public modal$: Observable | undefined> + + public Mode: typeof Mode = Mode + public UIResourceStatus: typeof UIResourceStatus = UIResourceStatus + + private loadingElement: HTMLIonLoadingElement | undefined + private alertElement: HTMLIonAlertElement | undefined + private modalElement: HTMLIonModalElement | undefined + private readonly ngDestroyed$: Subject = new Subject() + + constructor( + private readonly store: Store, + private readonly uiEventService: UiEventService, + private readonly modalController: ModalController + ) { + this.mode$ = this.store.select(fromDeserializedDetail.selectMode) + this.title$ = this.store.select(fromDeserializedDetail.selectTitle) + this.button$ = this.store.select(fromDeserializedDetail.selectButton) + + this.loader$ = this.store.select(fromDeserializedDetail.selectLoader) + this.alert$ = this.store.select(fromDeserializedDetail.selectAlert) + this.modal$ = this.store.select(fromDeserializedDetail.selectModal) + + this.transactionsDetails$ = this.store.select(fromDeserializedDetail.selectTransactionsDetails) + this.messages$ = this.store.select(fromDeserializedDetail.selectMessagesData) + + this.rawData$ = this.store.select(fromDeserializedDetail.selectRaw) + + // FIXME [#210] set debounce time + this.loader$.pipe(debounceTime(0), distinctUntilChanged(), takeUntil(this.ngDestroyed$)).subscribe(this.showOrHideLoader.bind(this)) + this.alert$.pipe(takeUntil(this.ngDestroyed$)).subscribe(this.showOrDismissAlert.bind(this)) + this.modal$.pipe(takeUntil(this.ngDestroyed$)).subscribe(this.showOrDismissModal.bind(this)) + + this.store.dispatch(actions.viewInitialization()) + } + + public ngOnDestroy(): void { + this.ngDestroyed$.next() + this.ngDestroyed$.complete() + } + + public continue(): void { + this.store.dispatch(actions.approved()) + } + + private async showOrHideLoader(task: (UIAction & { userInput: actions.UserInput }) | undefined): Promise { + this.loadingElement?.dismiss().catch(handleErrorLocal(ErrorCategory.IONIC_LOADER)) + if (task?.status === UIActionStatus.PENDING) { + this.loadingElement = await this.uiEventService.getTranslatedLoader(this.getLoaderData(task.value)) + + // return this.loadingElement.present().catch(handleErrorLocal(ErrorCategory.IONIC_LOADER)) + + // FIXME [#210]: replace with the above once the performance issue is resolved + await this.loadingElement.present().catch(handleErrorLocal(ErrorCategory.IONIC_LOADER)) + this.store.dispatch(actions.continueApproved({ userInput: task.userInput })) + // [#210] + } else { + this.loadingElement = undefined + } + } + + private async showOrDismissAlert(alert: UIAction | undefined): Promise { + this.alertElement?.dismiss().catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) + if (alert?.status === UIActionStatus.PENDING) { + this.alertElement = await this.uiEventService.getTranslatedAlert(this.getAlertData(alert.value)) + this.alertElement.present().catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) + + return this.alertElement + .onWillDismiss() + .then(() => { + this.store.dispatch(actions.alertDismissed({ id: alert.id })) + }) + .catch(handleErrorLocal(ErrorCategory.IONIC_ALERT)) + } else { + this.alertElement = undefined + } + } + + private async showOrDismissModal(modal: UIAction | undefined): Promise { + this.modalElement?.dismiss().catch(handleErrorLocal(ErrorCategory.IONIC_MODAL)) + if (modal?.status === UIActionStatus.PENDING) { + const [modalOptions, onDismissAction]: [ModalOptions, ModalOnDismissAction] = this.getModalData(modal.value) + this.modalElement = await this.modalController.create(modalOptions) + this.modalElement.present().catch(handleErrorLocal(ErrorCategory.IONIC_MODAL)) + + return this.modalElement + .onWillDismiss() + .then( + (data: OverlayEventDetail): Promise => { + this.store.dispatch(actions.modalDismissed({ id: modal.id })) + + return onDismissAction(data) + } + ) + .catch(handleErrorLocal(ErrorCategory.IONIC_MODAL)) + } else { + this.modalElement = undefined + } + } + + private getLoaderData(task: Task): LoadingOptions { + switch (task) { + case 'signTransaction': + return this.signTransactionLoader() + case 'signMessage': + return this.signMessageLoader() + case 'generic': + return this.genericLoader() + } + } - public title: string - public transactionInfos: SignTransactionInfo[] - public type: IACMessageType - public signTransactionRequests: IACMessageDefinitionObject[] + private signTransactionLoader(): LoadingOptions { + return { + message: 'deserialized-detail.loader.sign-transaction.message' + } + } + + private signMessageLoader(): LoadingOptions { + return { + message: 'deserialized-detail.loader.sign-message.message' + } + } - public iacMessageType: IACMessageType + private genericLoader(): LoadingOptions { + return { + message: 'deserialized-detail.loader.generic.message' + } + } - constructor(private readonly navigationService: NavigationService, private readonly translateService: TranslateService) {} + private getAlertData(alert: Alert): AlertOptions { + switch (alert.type) { + case 'bip39Passphrase': + return this.bip39PassphraseAlert() + case 'bip39PassphraseError': + return this.bip39PassphraseErrorAlert() + case 'secretNotFound': + return this.secretNotFoundErrorAlert() + case 'unknownError': + return this.unknownErrorAlert(alert.message) + default: + return {} + } + } - public async ionViewWillEnter(): Promise { - const state = this.navigationService.getState() - if (state.transactionInfos) { - this.transactionInfos = state.transactionInfos - this.type = state.type - this.iacMessageType = this.transactionInfos[0].signTransactionRequest.type - this.signTransactionRequests = this.transactionInfos.map((info) => info.signTransactionRequest) + private bip39PassphraseAlert(): AlertOptions { + return { + header: 'deserialized-detail.alert.bip39-passphrase.header', + message: 'deserialized-detail.alert.bip39-passphrase.message', + backdropDismiss: false, + inputs: [ + { + name: 'bip39Passphrase', + type: 'password', + placeholder: 'deserialized-detail.alert.bip39-passphrase.input-placeholder_label' + } + ], + buttons: [ + { + text: 'deserialized-detail.alert.bip39-passphrase.button_label', + handler: async (result: { bip39Passphrase: string }): Promise => { + const passphrase: string = result.bip39Passphrase ?? '' + this.store.dispatch(actions.bip39PassphraseProvided({ passphrase })) + } + } + ] + } + } - const display = this.type === IACMessageType.TransactionSignRequest ? 'transaction' : 'message' - this.title = this.translateService.instant(`deserialized-detail.${display}.title`) + private bip39PassphraseErrorAlert(): AlertOptions { + return { + header: 'deserialized-detail.alert.bip39-passphrase-error.header', + message: 'deserialized-detail.alert.bip39-passphrase-error.message', + backdropDismiss: false, + buttons: [ + { + text: 'deserialized-detail.alert.bip39-passphrase-error.button_label' + } + ] } } + + private secretNotFoundErrorAlert(): AlertOptions { + return { + header: 'deserialized-detail.alert.secret-not-found-error.header', + message: 'deserialized-detail.alert.secret-not-found-error.message', + backdropDismiss: false, + buttons: [ + { + text: 'deserialized-detail.alert.secret-not-found-error.button_label' + } + ] + } + } + + private unknownErrorAlert(message?: string): AlertOptions { + return { + header: 'deserialized-detail.alert.unknown-error.header', + message: message ?? 'deserialized-detail.alert.unknown-error.message', + backdropDismiss: true, + buttons: [ + { + text: 'deserialized-detail.alert.unknown-error.button_label' + } + ] + } + } + + private getModalData(modal: Modal): [ModalOptions, ModalOnDismissAction] { + switch (modal) { + case 'selectSigningAccount': + return this.selectSigningAccountModal() + default: + assertNever('getModalForType', modal) + } + } + + private selectSigningAccountModal(): [ModalOptions, ModalOnDismissAction] { + const options: ModalOptions = { + component: SelectAccountPage, + componentProps: { type: 'message-signing' } + } + + const action: ModalOnDismissAction = async (modalData: OverlayEventDetail): Promise => { + if (modalData.data === undefined || typeof modalData.data !== 'string') { + return + } + + this.store.dispatch(actions.signingProtocolProvided({ protocol: modalData.data as ProtocolSymbols })) + } + + return [options, action] + } } diff --git a/src/app/pages/deserialized-detail/deserialized-detail.reducer.ts b/src/app/pages/deserialized-detail/deserialized-detail.reducer.ts new file mode 100644 index 00000000..91c7839f --- /dev/null +++ b/src/app/pages/deserialized-detail/deserialized-detail.reducer.ts @@ -0,0 +1,399 @@ +// tslint:disable: typedef +// tslint:disable: max-file-line-count +import { assertNever, flattened, generateGUID, UIAction, UIActionStatus, UIResource, UIResourceStatus } from '@airgap/angular-core' +import { IAirGapTransaction } from '@airgap/coinlib-core' +import { createFeatureSelector, createReducer, createSelector, on } from '@ngrx/store' + +import * as fromRoot from '../../app.reducers' + +import * as actions from './deserialized-detail.actions' +import { + Alert, + Bip39PassphraseAlert, + Bip39PassphraseErrorAlert, + DeserializedMessage, + DeserializedSignedTransaction, + DeserializedTransaction, + DeserializedUnsignedMessage, + DeserializedUnsignedTransaction, + Modal, + Mode, + Payload, + SecretNotFoundErrorAlert, + UnknownErrorAlert, + UnsignedMessage, + Task +} from './deserialized.detail.types' + +/**************** STATE ****************/ + +const MAX_BIP39_PASSPHRASE_TRIES = 1 +export interface FeatureState { + mode: Mode | undefined + title: string + button: string | undefined + // loader: UIAction | undefined + // FIXME [#210] replace with the above once the performance issue is resolved + loader: (UIAction & { userInput: actions.UserInput }) | undefined + // [#210] + alert: UIAction | undefined + modal: UIAction | undefined + transactions: UIResource + messages: UIResource + raw: UIResource + bip39PassphraseTries: number +} + +export interface State extends fromRoot.State { + deserializedDetail: FeatureState +} + +/**************** Reducers ****************/ + +const initialState: FeatureState = { + mode: undefined, + title: '', + button: undefined, + loader: undefined, + alert: undefined, + modal: undefined, + transactions: { + value: undefined, + status: UIResourceStatus.IDLE + }, + messages: { + value: undefined, + status: UIResourceStatus.IDLE + }, + raw: { + value: undefined, + status: UIResourceStatus.IDLE + }, + bip39PassphraseTries: 0 +} + +export const reducer = createReducer( + initialState, + on(actions.navigationDataLoading, (state) => ({ + ...state, + loader: undefined, + alert: undefined, + modal: undefined, + transactions: { + value: state.transactions.value, + status: UIResourceStatus.LOADING + }, + messages: { + value: state.messages.value, + status: UIResourceStatus.LOADING + }, + raw: { + value: state.raw.value, + status: UIResourceStatus.LOADING + } + })), + on(actions.navigationDataLoaded, (state, { mode, title, button, transactions, messages, raw }) => ({ + ...state, + mode, + title, + button, + loader: undefined, + alert: undefined, + modal: undefined, + transactions: { + value: transactions, + status: UIResourceStatus.SUCCESS + }, + messages: { + value: messages, + status: UIResourceStatus.SUCCESS + }, + raw: { + value: raw, + status: UIResourceStatus.SUCCESS + } + })), + on(actions.navigationDataLoadingError, (state, { mode, title, button, raw }) => ({ + ...state, + mode, + title, + button, + loader: undefined, + alert: undefined, + modal: undefined, + transactions: { + value: undefined, + status: UIResourceStatus.ERROR + }, + messages: { + value: undefined, + status: UIResourceStatus.ERROR + }, + raw: { + value: raw, + status: UIResourceStatus.SUCCESS + } + })), + on(actions.transactionsSigned, (state, { transactions }) => ({ + ...state, + loader: undefined, + alert: undefined, + transactions: { + value: (state.transactions.value ?? []).concat(transactions), + status: state.transactions.status + } + })), + on(actions.messagesSigned, (state, { messages }) => ({ + ...state, + loader: undefined, + alert: undefined, + modal: undefined, + messages: { + value: (state.messages.value ?? []).concat(messages), + status: state.transactions.status + } + })), + on(actions.loaderDismissed, (state, { id }) => ({ + ...state, + loader: + state.loader !== undefined + ? { + id: state.loader.id, + value: state.loader.value, + status: id === state.loader.id ? UIActionStatus.HANDLED : state.loader.status, + userInput: id === state.loader.id ? {} : state.loader.userInput + } + : undefined + })), + on(actions.alertDismissed, (state, { id }) => ({ + ...state, + alert: + state.alert !== undefined + ? { + id: state.alert.id, + value: state.alert.value, + status: id === state.alert.id ? UIActionStatus.HANDLED : state.alert.status + } + : undefined, + modal: undefined + })), + on(actions.modalDismissed, (state, { id }) => ({ + ...state, + alert: undefined, + modal: + state.modal !== undefined + ? { + id: state.modal.id, + value: state.modal.value, + status: id === state.modal.id ? UIActionStatus.HANDLED : state.modal.status + } + : undefined + })), + on(actions.invalidBip39Passphrase, (state) => { + return state.bip39PassphraseTries < MAX_BIP39_PASSPHRASE_TRIES + ? { + ...state, + loader: undefined, + alert: { + id: generateGUID(), + value: { type: 'bip39Passphrase' as Bip39PassphraseAlert['type'] }, + status: UIActionStatus.PENDING + }, + modal: undefined, + bip39PassphraseTries: state.bip39PassphraseTries + 1 + } + : { + ...state, + loader: undefined, + alert: { + id: generateGUID(), + value: { type: 'bip39PassphraseError' as Bip39PassphraseErrorAlert['type'] }, + status: UIActionStatus.PENDING + }, + modal: undefined, + bip39PassphraseTries: 0 + } + }), + on(actions.secretNotFound, (state) => ({ + ...state, + alert: { + id: generateGUID(), + value: { type: 'secretNotFound' as SecretNotFoundErrorAlert['type'] }, + status: UIActionStatus.PENDING + }, + modal: undefined + })), + on(actions.protocolNotFound, (state) => ({ + ...state, + loader: undefined, + alert: undefined, + modal: { + id: generateGUID(), + value: 'selectSigningAccount' as Modal, + status: UIActionStatus.PENDING + } + })), + on(actions.runningBlockingTask, (state, { task, userInput }) => ({ + ...state, + loader: { + id: generateGUID(), + value: task, + status: UIActionStatus.PENDING, + userInput + } + })), + on(actions.unknownError, (state, { message }) => ({ + ...state, + loader: undefined, + alert: { + id: generateGUID(), + value: { + type: 'unknownError' as UnknownErrorAlert['type'], + message: message?.length > 0 ? message : undefined + }, + status: UIActionStatus.PENDING + }, + modal: undefined + })) +) + +/**************** Selectors ****************/ + +export const selectFeatureState = createFeatureSelector('deserializedDetail') + +export const selectMode = createSelector(selectFeatureState, (state: FeatureState): FeatureState['mode'] => state.mode) +export const selectTitle = createSelector(selectFeatureState, (state: FeatureState): FeatureState['title'] => state.title) +export const selectButton = createSelector(selectFeatureState, (state: FeatureState): FeatureState['button'] => state.button) +export const selectLoader = createSelector(selectFeatureState, (state: FeatureState): FeatureState['loader'] => state.loader) +export const selectAlert = createSelector(selectFeatureState, (state: FeatureState): FeatureState['alert'] => state.alert) +export const selectModal = createSelector(selectFeatureState, (state: FeatureState): FeatureState['modal'] => state.modal) + +export const selectTransactions = createSelector( + selectFeatureState, + (state: FeatureState): UIResource | undefined => state.transactions +) + +const createSelectTransaction = ( + type: DeserializedTransaction['type'] +): ((transactions: UIResource) => UIResource) => { + return (transactions: UIResource): UIResource => ({ + value: transactions.value?.filter((transaction: DeserializedTransaction) => transaction.type === type) as T[], + status: transactions.status + }) +} +export const selectUnsignedTransactions = createSelector( + selectTransactions, + createSelectTransaction('unsigned') +) +export const selectSignedTransactions = createSelector(selectTransactions, createSelectTransaction('signed')) + +const getTransactionsDetails = ( + transactions: UIResource, + type?: DeserializedTransaction['type'] +): UIResource => { + const details: IAirGapTransaction[][] | undefined = transactions.value + ?.filter((transaction: DeserializedTransaction) => (type !== undefined ? transaction.type === type : true)) + .map((transaction: DeserializedTransaction) => transaction.details) + + return { + value: details !== undefined ? flattened(details) : undefined, + status: transactions.status + } +} + +export const selectTransactionsDetails = createSelector( + selectMode, + selectTransactions, + (mode: Mode, transactions: UIResource): UIResource => { + switch (mode) { + case Mode.SIGN_TRANSACTION: + return getTransactionsDetails(transactions, 'unsigned') + default: + return { + value: undefined, + status: transactions.status + } + } + } +) +export const selectUnsignedTransactionsDetails = createSelector(selectUnsignedTransactions, getTransactionsDetails) +export const selectSignedTransactionsDetails = createSelector(selectSignedTransactions, getTransactionsDetails) + +export const selectMessages = createSelector(selectFeatureState, (state: FeatureState) => state.messages) + +const createSelectMessage = ( + type: DeserializedMessage['type'] +): ((messages: UIResource) => UIResource) => { + return (messages: UIResource): UIResource => ({ + value: messages.value?.filter((message: DeserializedMessage) => message.type === type) as T[], + status: messages.status + }) +} + +export const selectUnsignedMessages = createSelector(selectMessages, createSelectMessage('unsigned')) + +const getMessagesData = ( + messages: UIResource, + type?: DeserializedMessage['type'] +): UIResource => { + const details: UnsignedMessage[] | undefined = messages.value + ?.filter((message: DeserializedMessage) => (type !== undefined ? message.type === type : true)) + .map((message: DeserializedMessage) => { + const blake2bHash: string | undefined = message.type === 'unsigned' ? message.blake2bHash : undefined + + return { data: message.data.message, blake2bHash } + }) + + return { + value: details, + status: messages.status + } +} + +export const selectMessagesData = createSelector( + selectMode, + selectMessages, + (mode: Mode, messages: UIResource): UIResource => { + switch (mode) { + case Mode.SIGN_MESSAGE: + return getMessagesData(messages, 'unsigned') + default: + return { + value: undefined, + status: messages.status + } + } + } +) + +export const selectUnsignedMessagesData = createSelector(selectUnsignedMessages, getMessagesData) + +export const selectRaw = createSelector(selectFeatureState, (state: FeatureState) => state.raw) + +export const selectFinalPayload = createSelector( + selectMode, + selectUnsignedTransactions, + selectSignedTransactions, + selectUnsignedMessages, + ( + mode: Mode, + unsignedTransactions: UIResource, + _signedTransactions: UIResource, + unsignedMessages: UIResource + ): Payload => { + switch (mode) { + case Mode.SIGN_TRANSACTION: + return { + mode, + data: unsignedTransactions.value + } + case Mode.SIGN_MESSAGE: + return { + mode, + data: unsignedMessages.value + } + default: + assertNever('selectFinalPayload', mode) + } + } +) diff --git a/src/app/pages/deserialized-detail/deserialized.detail.types.ts b/src/app/pages/deserialized-detail/deserialized.detail.types.ts new file mode 100644 index 00000000..c419387a --- /dev/null +++ b/src/app/pages/deserialized-detail/deserialized.detail.types.ts @@ -0,0 +1,108 @@ +import { + AirGapWallet, + IAirGapTransaction, + MessageSignRequest, + MessageSignResponse, + ProtocolSymbols, + SignedTransaction, + UnsignedTransaction +} from '@airgap/coinlib-core' + +export enum Mode { + SIGN_TRANSACTION = 0, + SIGN_MESSAGE = 1 +} + +/**************** Task ****************/ + +export type Task = 'signTransaction' | 'signMessage' | 'generic' + +/**************** Alert ****************/ +export interface Bip39PassphraseAlert { + type: 'bip39Passphrase' +} + +export interface Bip39PassphraseErrorAlert { + type: 'bip39PassphraseError' +} + +export interface SecretNotFoundErrorAlert { + type: 'secretNotFound' +} + +export interface UnknownErrorAlert { + type: 'unknownError' + message?: string +} + +export type Alert = Bip39PassphraseAlert | Bip39PassphraseErrorAlert | SecretNotFoundErrorAlert | UnknownErrorAlert + +/**************** Modal ****************/ + +export type Modal = 'selectSigningAccount' + +/**************** DeserializedTransasction ****************/ + +export interface DeserializedUnsignedTransaction { + type: 'unsigned' + id: string + details: IAirGapTransaction[] + data: UnsignedTransaction + wallet: AirGapWallet +} + +export interface DeserializedSignedTransaction { + type: 'signed' + id: string + details: IAirGapTransaction[] + data: SignedTransaction & Pick + wallet: AirGapWallet +} + +export type DeserializedTransaction = DeserializedUnsignedTransaction | DeserializedSignedTransaction + +export function isDeserializedTransaction(data: unknown): data is DeserializedTransaction { + return data instanceof Object && 'type' in data && 'details' in data && 'data' in data && 'wallet' in data +} + +/**************** DeserializedMessage ****************/ + +export interface DeserializedUnsignedMessage { + type: 'unsigned' + id: string + protocol: ProtocolSymbols | undefined + data: MessageSignRequest + blake2bHash: string | undefined + wallet: AirGapWallet | undefined +} + +export interface DeserializedSignedMessage { + type: 'signed' + id: string + protocol: ProtocolSymbols | undefined + data: MessageSignResponse & Pick + wallet: AirGapWallet | undefined +} + +export type DeserializedMessage = DeserializedUnsignedMessage | DeserializedSignedMessage + +/**************** DeserializedPayload ****************/ + +export interface SignTransactionPayload { + mode: Mode.SIGN_TRANSACTION + data: DeserializedUnsignedTransaction[] +} + +export interface SignMessagePayload { + mode: Mode.SIGN_MESSAGE + data: DeserializedUnsignedMessage[] +} + +export type Payload = SignTransactionPayload | SignMessagePayload + +/**************** Resources ****************/ + +export interface UnsignedMessage { + data: string + blake2bHash?: string +} diff --git a/src/app/pages/secret-edit/secret-edit.page.html b/src/app/pages/secret-edit/secret-edit.page.html index cbecdf0b..2a4c9eba 100644 --- a/src/app/pages/secret-edit/secret-edit.page.html +++ b/src/app/pages/secret-edit/secret-edit.page.html @@ -81,6 +81,19 @@

{{ 'secret-edit.social-recovery.label' | translate }}

+

{{ 'secret-edit.advanced' | translate }}

+ + +
+ +
+
+ +

{{ 'secret-edit.bip85.generate' | translate }}

+

+
+
+

{{ 'secret-edit.show-mnemonic.label' | translate }}

diff --git a/src/app/pages/secret-edit/secret-edit.page.ts b/src/app/pages/secret-edit/secret-edit.page.ts index 8e8b0ed9..f27711db 100644 --- a/src/app/pages/secret-edit/secret-edit.page.ts +++ b/src/app/pages/secret-edit/secret-edit.page.ts @@ -86,6 +86,12 @@ export class SecretEditPage { .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) } + public goToBip85ChildSeed(): void { + this.navigationService + .routeWithState('/bip85-generate', { secret: this.secret }) + .catch(handleErrorLocal(ErrorCategory.IONIC_NAVIGATION)) + } + public async resetRecoveryPassword(): Promise { try { const recoveryKey = await this.secretsService.resetRecoveryPassword(this.secret) diff --git a/src/app/pages/secret-import/secret-import.page.html b/src/app/pages/secret-import/secret-import.page.html index 5e22558a..683d8cc1 100644 --- a/src/app/pages/secret-import/secret-import.page.html +++ b/src/app/pages/secret-import/secret-import.page.html @@ -1,5 +1,5 @@ - + diff --git a/src/app/pages/tabs/tabs.page.html b/src/app/pages/tabs/tabs.page.html index 04a7d2c8..24f72961 100644 --- a/src/app/pages/tabs/tabs.page.html +++ b/src/app/pages/tabs/tabs.page.html @@ -1,4 +1,4 @@ - + diff --git a/src/app/pages/tabs/tabs.page.ts b/src/app/pages/tabs/tabs.page.ts index 99fb1f78..31d49e8b 100644 --- a/src/app/pages/tabs/tabs.page.ts +++ b/src/app/pages/tabs/tabs.page.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core' +import { IonTabs } from '@ionic/angular' @Component({ selector: 'airgap-tabs', @@ -6,5 +7,21 @@ import { Component } from '@angular/core' styleUrls: ['tabs.page.scss'] }) export class TabsPage { + private activeTab?: HTMLElement + constructor() {} + + tabChange(tabsRef: IonTabs) { + this.activeTab = tabsRef.outlet.activatedView.element + } + + ionViewWillEnter() { + this.propagateToActiveTab('ionViewWillEnter') + } + + private propagateToActiveTab(eventName: string) { + if (this.activeTab) { + this.activeTab.dispatchEvent(new CustomEvent(eventName)) + } + } } diff --git a/src/app/pages/warning-modal/warning-modal.page.ts b/src/app/pages/warning-modal/warning-modal.page.ts index e7d06abb..5a5cfdab 100644 --- a/src/app/pages/warning-modal/warning-modal.page.ts +++ b/src/app/pages/warning-modal/warning-modal.page.ts @@ -61,13 +61,17 @@ export class WarningModalPage implements AfterContentInit { if (this.errorType === Warning.SECURE_STORAGE) { this.translateService - .get(['warnings-modal.secure-storage.title', 'warnings-modal.secure-storage.description']) + .get([ + 'warnings-modal.secure-storage.title', + 'warnings-modal.secure-storage.description', + 'warnings-modal.secure-storage.button-text_label' + ]) .subscribe((values) => { this.title = values['warnings-modal.secure-storage.title'] this.description = values['warnings-modal.secure-storage.description'] + this.buttonText = values['warnings-modal.secure-storage.button-text_label'] }) this.imageUrl = './assets/img/screenshot_detected.svg' - this.buttonText = 'warnings-modal.secure-storage.button-text_label' this.handler = (): void => { this.secureStorageService.secureDevice().catch(handleErrorLocal(ErrorCategory.SECURE_STORAGE)) } diff --git a/src/app/services/iac/iac.service.ts b/src/app/services/iac/iac.service.ts index c2d1339b..149b5168 100644 --- a/src/app/services/iac/iac.service.ts +++ b/src/app/services/iac/iac.service.ts @@ -125,53 +125,52 @@ export class IACService extends BaseIACService { messageDefinitionObjects: IACMessageDefinitionObject[], _scanAgainCallback: Function ): Promise { - const transactionInfos: SignTransactionInfo[] = ( - await Promise.all( - messageDefinitionObjects.map( - async (messageDefinitionObject): Promise => { - const messageSignRequest: MessageSignRequest = messageDefinitionObject.payload as MessageSignRequest - - let correctWallet = this.secretsService.findWalletByPublicKeyAndProtocolIdentifier( + const transactionInfos: SignTransactionInfo[] = await Promise.all( + messageDefinitionObjects.map( + async (messageDefinitionObject): Promise => { + const messageSignRequest: MessageSignRequest = messageDefinitionObject.payload as MessageSignRequest + + let correctWallet = this.secretsService.findWalletByPublicKeyAndProtocolIdentifier( + messageSignRequest.publicKey, + messageDefinitionObject.protocol + ) + + // If we can't find a wallet for a protocol, we will try to find the "base" wallet and then create a new + // wallet with the right protocol. This way we can sign all ERC20 transactions, but show the right amount + // and fee for all tokens we support. + if (!correctWallet) { + const baseWallet: AirGapWallet | undefined = this.secretsService.findBaseWalletByPublicKeyAndProtocolIdentifier( messageSignRequest.publicKey, messageDefinitionObject.protocol ) - // If we can't find a wallet for a protocol, we will try to find the "base" wallet and then create a new - // wallet with the right protocol. This way we can sign all ERC20 transactions, but show the right amount - // and fee for all tokens we support. - if (!correctWallet) { - const baseWallet: AirGapWallet | undefined = this.secretsService.findBaseWalletByPublicKeyAndProtocolIdentifier( - messageSignRequest.publicKey, - messageDefinitionObject.protocol - ) - - if (baseWallet) { - // If the protocol is not supported, use the base protocol for signing - const protocol = await this.protocolService.getProtocol(messageDefinitionObject.protocol) - try { - correctWallet = new AirGapWallet( - protocol, - baseWallet.publicKey, - baseWallet.isExtendedPublicKey, - baseWallet.derivationPath - ) - correctWallet.addresses = baseWallet.addresses - } catch (e) { - if (e.message === 'PROTOCOL_NOT_SUPPORTED') { - correctWallet = baseWallet - } + if (baseWallet) { + // If the protocol is not supported, use the base protocol for signing + const protocol = await this.protocolService.getProtocol(messageDefinitionObject.protocol) + try { + correctWallet = new AirGapWallet(protocol, baseWallet.publicKey, baseWallet.isExtendedPublicKey, baseWallet.derivationPath) + correctWallet.addresses = baseWallet.addresses + } catch (e) { + if (e.message === 'PROTOCOL_NOT_SUPPORTED') { + correctWallet = baseWallet } } } + } - return { - wallet: correctWallet, - signTransactionRequest: messageDefinitionObject + return { + wallet: correctWallet, + signTransactionRequest: { + ...messageDefinitionObject, + payload: { + ...messageSignRequest, + publicKey: correctWallet?.publicKey ?? '' // ignore public key if no account has been found + } } } - ) + } ) - ).filter((signTransactionDetails) => signTransactionDetails.wallet !== undefined) + ) this.navigationService .routeWithState('deserialized-detail', { diff --git a/src/app/services/sapling-native/sapling-native.service.ts b/src/app/services/sapling-native/sapling-native.service.ts new file mode 100644 index 00000000..802a2a3d --- /dev/null +++ b/src/app/services/sapling-native/sapling-native.service.ts @@ -0,0 +1,112 @@ +import { TezosSaplingExternalMethodProvider, TezosSaplingTransaction } from '@airgap/coinlib-core' +import { SaplingPartialOutputDescription, SaplingUnsignedSpendDescription } from '@airgap/sapling-wasm' +import { Inject, Injectable } from '@angular/core' +import { Platform } from '@ionic/angular' + +import { SAPLING_PLUGIN } from '../..//capacitor-plugins/injection-tokens' +import { SaplingPlugin } from '../../capacitor-plugins/definitions' + +@Injectable({ + providedIn: 'root' +}) +export class SaplingNativeService { + constructor(@Inject(SAPLING_PLUGIN) private readonly sapling: SaplingPlugin, private readonly platform: Platform) {} + + public async createExternalMethodProvider(): Promise { + const isSupported = this.platform.is('capacitor') && !this.platform.is('electron') && (await this.sapling.isSupported()).isSupported + + return isSupported + ? { + initParameters: this.initParameters(this.sapling), + withProvingContext: this.withProvingContext(this.sapling), + prepareSpendDescription: this.prepareSpendDescription(this.sapling), + preparePartialOutputDescription: this.preparePartialOutputDescription(this.sapling), + createBindingSignature: this.createBindingSignature(this.sapling) + } + : undefined + } + + private initParameters(saplingPlugin: SaplingPlugin): TezosSaplingExternalMethodProvider['initParameters'] { + return async (_spendParams: Buffer, _outputParams: Buffer): Promise => { + return saplingPlugin.initParameters() + } + } + + private withProvingContext(saplingPlugin: SaplingPlugin): TezosSaplingExternalMethodProvider['withProvingContext'] { + return async (action: (context: number) => Promise): Promise => { + const { context } = await saplingPlugin.initProvingContext() + const transaction = await action(parseInt(context)) + await saplingPlugin.dropProvingContext({ context }) + + return transaction + } + } + + private prepareSpendDescription(saplingPlugin: SaplingPlugin): TezosSaplingExternalMethodProvider['prepareSpendDescription'] { + return async ( + context: number, + spendingKey: Buffer, + address: Buffer, + rcm: string, + ar: Buffer, + value: string, + root: string, + merklePath: string + ): Promise => { + const { spendDescription: spendDescriptionHex } = await saplingPlugin.prepareSpendDescription({ + context: context.toString(), + spendingKey: spendingKey.toString('hex'), + address: address.toString('hex'), + rcm, + ar: ar.toString('hex'), + value, + root, + merklePath + }) + + const spendDescription: Buffer = Buffer.from(spendDescriptionHex, 'hex') + + return { + cv: spendDescription.slice(0, 32) /* 32 bytes */, + rt: spendDescription.slice(32, 64) /* 32 bytes */, + nf: spendDescription.slice(64, 96) /* 32 bytes */, + rk: spendDescription.slice(96, 128) /* 32 bytes */, + proof: spendDescription.slice(128, 320) /* 48 + 96 + 48 bytes */ + } + } + } + + private preparePartialOutputDescription( + saplingPlugin: SaplingPlugin + ): TezosSaplingExternalMethodProvider['preparePartialOutputDescription'] { + return async (context: number, address: Buffer, rcm: Buffer, esk: Buffer, value: string): Promise => { + const { outputDescription: outputDescriptionHex } = await saplingPlugin.preparePartialOutputDescription({ + context: context.toString(), + address: address.toString('hex'), + rcm: rcm.toString('hex'), + esk: esk.toString('hex'), + value + }) + + const outputDescription: Buffer = Buffer.from(outputDescriptionHex, 'hex') + + return { + cv: outputDescription.slice(0, 32) /* 32 bytes */, + cm: outputDescription.slice(32, 64) /* 32 bytes */, + proof: outputDescription.slice(64, 256) /* 48 + 96 + 48 bytes */ + } + } + } + + private createBindingSignature(saplingPlugin: SaplingPlugin): TezosSaplingExternalMethodProvider['createBindingSignature'] { + return async (context: number, balance: string, sighash: Buffer): Promise => { + const { bindingSignature } = await saplingPlugin.createBindingSignature({ + context: context.toString(), + balance, + sighash: sighash.toString('hex') + }) + + return Buffer.from(bindingSignature, 'hex') + } + } +} diff --git a/src/app/services/secrets/secrets.service.ts b/src/app/services/secrets/secrets.service.ts index 5972252e..d8d8dfc6 100644 --- a/src/app/services/secrets/secrets.service.ts +++ b/src/app/services/secrets/secrets.service.ts @@ -2,7 +2,7 @@ import { ProtocolService } from '@airgap/angular-core' import { Injectable } from '@angular/core' import { AlertController, LoadingController } from '@ionic/angular' import { AirGapWallet, ICoinProtocol } from '@airgap/coinlib-core' -import { ProtocolSymbols } from '@airgap/coinlib-core/utils/ProtocolSymbols' +import { MainProtocolSymbols, ProtocolSymbols } from '@airgap/coinlib-core/utils/ProtocolSymbols' import { SerializedAirGapWallet } from '@airgap/coinlib-core/wallet/AirGapWallet' import * as bip39 from 'bip39' import { Observable, ReplaySubject } from 'rxjs' @@ -287,6 +287,7 @@ export class SecretsService { ) } } catch (error) { + console.log(error) // minimal solution without dependency if (error.message.startsWith('Expected BIP32 derivation path')) { error.message = 'Expected BIP32 derivation path, got invalid string' @@ -300,6 +301,12 @@ export class SecretsService { } } + public getKnownViewingKeys(): string[] { + return this.getWallets() + .filter((wallet: AirGapWallet) => wallet.protocol.identifier === MainProtocolSymbols.XTZ_SHIELDED) + .map((wallet: AirGapWallet) => wallet.publicKey) + } + public async showAlert(title: string, message: string): Promise { const alert: HTMLIonAlertElement = await this.alertCtrl.create({ header: title, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e8ae75d7..1ba5230c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -87,6 +87,7 @@ "title": " Your Secret", "text": "Give your secret a name and select the security level.", "secret_input_label": "Label of your secret", + "advanced": "Advanced Options", "security-level": { "heading": "Security Level", "text": "You can encrypt your secret additionally using a passcode." @@ -120,6 +121,10 @@ "copied": "Recovery key copied", "reset-error": "Could not set the recovery key" }, + "bip85": { + "generate": "Generate BIP85 Child Mnemonic", + "text": "Securely generate a child mnemonic out of your master mnemonic." + }, "show-mnemonic": { "label": "Show Mnemonic", "text": "Display the mnemonic associated with this secret.", @@ -133,6 +138,7 @@ }, "confirm_label": "Confirm" }, + "secret-edit-delete-popover": { "title": "Confirm Secret Removal", "text": "Do you really want to remove this secret? You won't be able to restore it without a backup!", @@ -281,10 +287,47 @@ "deserialized-detail": { "sign_text": "You're about to sign:", "transaction": { - "title": "Sign Transaction" + "title": "Sign Transaction", + "button_label": "Sign Transaction", + "unreadable_warning": "We were not able to extract information from this transaction. This does not mean that the transaction is invalid - Please make sure that you know what you are doing, and you can verify that you know that this transaction contains the correct data." }, "message": { - "title": "Sign Message" + "title": "Sign Message", + "button_label": "Sign Message" + }, + "loader": { + "sign-transaction": { + "message": "Signing transaction..." + }, + "sign-message": { + "message": "Signing message..." + }, + "generic": { + "message": "Loading..." + } + }, + "alert": { + "bip39-passphrase": { + "header": "BIP-39 Passphrase", + "message": "If you have set a BIP-39 passphrase, please enter it here.", + "input-placeholder_label": "Passphrase", + "button_label": "Ok" + }, + "bip39-passphrase-error": { + "header": "BIP-39 Passphrase", + "message": "Public keys do not match. Did you enter the correct BIP-39 Passphrase?", + "button_label": "Ok" + }, + "secret-not-found-error": { + "header": "Secret not found", + "message": "No secret found for this public key", + "button_label": "Ok" + }, + "unknown-error": { + "header": "Error", + "message": "Something went wrong!", + "button_label": "Ok" + } } }, "transaction-signed": { @@ -380,6 +423,36 @@ "understood_label": "I understand and accept" } }, + + "bip85-generate": { + "title": "Generate BIP85", + "text": "BIP85 allows you to securely derive a new mnemonic out of your main mnemonic. As long as you have access to your main mnemonic, you will always be able to re-generate your child mnemonics.", + "mnemonic-length": "Mnemonic Length", + "index": "Index", + "generate": "Generate", + "advanced_label": "Advanced Mode", + "bip39-passphrase": "BIP-39 Passphrase", + "bip39-passphrase-reveal": "Reveal Passphrase", + "alert": { + "header": "BIP-39 Passphrase", + "message": "You set a BIP39 Passphrase. You will need to enter this passphrase again when you try to derive the same child key!", + "understand": "I understand" + } + }, + + "bip85-show": { + "title": "Show BIP85 Details", + "text": "Write down all the words on a piece of paper. You will have to verify the mnemonic on the next page.", + "mnemonic-length": "Mnemonic Length", + "index": "Index", + "validate": "Validate" + }, + + "bip85-validate": { + "title": "Verify BIP85", + "text": "Match the order of your recovery phrase by selecting the correct words." + }, + "message-signing-request": { "title": "Signed Message", "payload_label": "Message to sign. Make sure you know what you are signing.", diff --git a/src/assets/sapling/sapling-output.params b/src/assets/sapling/sapling-output.params new file mode 100644 index 00000000..01760fa4 Binary files /dev/null and b/src/assets/sapling/sapling-output.params differ diff --git a/src/assets/sapling/sapling-spend.params b/src/assets/sapling/sapling-spend.params new file mode 100644 index 00000000..b91cd771 Binary files /dev/null and b/src/assets/sapling/sapling-spend.params differ diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 7c18a1de..fa93c27b 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -78,6 +78,9 @@ airgap-account-add, airgap-account-address, airgap-account-share, +airgap-bip85-generate, +airgap-bip85-show, +airgap-bip85-validate, airgap-secret-create, airgap-secret-generate, airgap-secret-import, @@ -98,6 +101,9 @@ airgap-secret-show, airgap-social-recovery-show-share, airgap-social-recovery-validate-share, airgap-current-secret, +airgap-bip85-generate, +airgap-bip85-show, +airgap-bip85-validate, airgap-local-authentication-onboarding, airgap-deserialized-detail { --ion-background-color: var(--ion-color-secondary); diff --git a/test-config/plugins-mocks.ts b/test-config/plugins-mocks.ts index 5d2b183f..b6b57e73 100644 --- a/test-config/plugins-mocks.ts +++ b/test-config/plugins-mocks.ts @@ -2,7 +2,7 @@ import { AppInfoPlugin } from '@airgap/angular-core' import { AppPlugin, ClipboardPlugin, SplashScreenPlugin, StatusBarPlugin } from '@capacitor/core' import { newSpy } from './unit-test-helper' -import { SecurityUtilsPlugin } from 'src/app/capacitor-plugins/definitions' +import { SaplingPlugin, SecurityUtilsPlugin } from 'src/app/capacitor-plugins/definitions' export function createAppSpy(): AppPlugin { return jasmine.createSpyObj('AppPlugin', ['addListener', 'openUrl']) @@ -39,6 +39,10 @@ export function createSecurityUtilsSpy(): SecurityUtilsPlugin { ]) } +export function createSaplingSpy(): SaplingPlugin { + return jasmine.createSpyObj('SaplingPlugin', ['isSupported']) +} + export function createSplashScreenSpy(): SplashScreenPlugin { return jasmine.createSpyObj('SplashScreenPlugin', ['hide']) } @@ -59,6 +63,10 @@ export class AppInfoPluginMock { ) } +export class SaplingPluginMock { + public isSupported: jasmine.Spy = newSpy('isSupported', Promise.resolve(false)) +} + export class StatusBarMock { public setStyle: jasmine.Spy = newSpy('setStyle', Promise.resolve()) public setBackgroundColor: jasmine.Spy = newSpy('setBackgroundColor', Promise.resolve()) diff --git a/test-config/unit-test-helper.ts b/test-config/unit-test-helper.ts index e43a5e92..92ea7736 100644 --- a/test-config/unit-test-helper.ts +++ b/test-config/unit-test-helper.ts @@ -19,7 +19,7 @@ import { PlatformMock, ToastControllerMock } from './ionic-mocks' -import { AppInfoPluginMock, SplashScreenMock, StatusBarMock } from './plugins-mocks' +import { AppInfoPluginMock, SaplingPluginMock, SplashScreenMock, StatusBarMock } from './plugins-mocks' import { StorageMock } from './storage-mock' import { APP_CONFIG } from '@airgap/angular-core' import { appConfig } from 'src/app/config/app-config' @@ -28,6 +28,7 @@ export class UnitHelper { public readonly mockRefs = { appInfo: new AppInfoPluginMock(), platform: new PlatformMock(), + sapling: new SaplingPluginMock(), statusBar: new StatusBarMock(), splashScreen: new SplashScreenMock(), deeplink: new DeeplinkMock(), diff --git a/uitest/README.md b/uitest/README.md new file mode 100644 index 00000000..d6875597 --- /dev/null +++ b/uitest/README.md @@ -0,0 +1,30 @@ +# Airgap-autotests + +## Requirements + +* [Java 8 JDK](http://www.oracle.com/technetwork/java/javase/downloads/index.html) + +* [Maven](https://maven.apache.org/download.cgi) + +* [Appium server](http://appium.io/) + +* [Android emulator or real device] + +* [Path variable JAVA_HOME, maven] + +## Quickstart run test +1) Run emulator or connect you device +2) start appium server +``` +appium --port 4723 --relaxed-security +``` +3) edit config.json file set android version and other property like "androidVersion": "10.0" +4) run tests +``` +mvn test +``` +5) optional create report +``` +mvn allure:serve +``` + diff --git a/uitest/config.json b/uitest/config.json new file mode 100644 index 00000000..7b753149 --- /dev/null +++ b/uitest/config.json @@ -0,0 +1,15 @@ +{ + "platform": "android", + "noReset": "false", + "appiumServer": "http://ale.papers.tech:4724/wd/hub", + + "androidVersion": "11.0", + "apkPath": "/home/airgap-signed.apk", + + "iOSversion": "13.4", + "appPath": "/Users/aleksandr/Downloads/MOV-PA.app", + "deviceName": "iPhone 8 Plus", + "bundleId": "", + "xcodeOrgId": "", + "updatedWDABundleId": "" +} diff --git a/uitest/pom.xml b/uitest/pom.xml new file mode 100644 index 00000000..67b87938 --- /dev/null +++ b/uitest/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + org.example + airgap-vault + 1.0-SNAPSHOT + + + UTF-8 + + 1.8 + ${maven.compiler.source} + + + + 5.6.2 + + + + + + + io.appium + java-client + 7.3.0 + + + + + + log4j + log4j + 1.2.17 + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + + + + com.mashape.unirest + unirest-java + 1.4.9 + + + + + + commons-io + commons-io + 2.5 + + + + + + io.qameta.allure + allure-junit5 + 2.12.1 + test + + + + + + + + + maven-surefire-plugin + 2.22.0 + + + + + + + -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/1.9.4/aspectjweaver-1.9.4.jar" + + + ${project.build.directory}/allure-results + https://github.com/vinogradoff/allure-maven-junit5-example/{} + + + + + + org.aspectj + aspectjweaver + 1.9.4 + + + + + + + + + io.qameta.allure + allure-maven + 2.10.0 + + + 2.12.1 + + + + + + + + + + \ No newline at end of file diff --git a/uitest/src/main/resources/log4j.properties b/uitest/src/main/resources/log4j.properties new file mode 100644 index 00000000..b60d3f4a --- /dev/null +++ b/uitest/src/main/resources/log4j.properties @@ -0,0 +1,16 @@ +# Root logger option +log4j.rootLogger=INFO, file, stdout + +# Direct log messages to a log file +log4j.appender.file=org.apache.log4j.RollingFileAppender +log4j.appender.file.File=${user.dir}/Log4j/log4j-application.log +log4j.appender.file.MaxFileSize=10MB +log4j.appender.file.MaxBackupIndex=10 +log4j.appender.file.layout=org.apache.log4j.PatternLayout +log4j.appender.file.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n + +# Direct log messages to stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n \ No newline at end of file diff --git a/uitest/src/test/java/Enums/TestResultStatus.java b/uitest/src/test/java/Enums/TestResultStatus.java new file mode 100644 index 00000000..7f754235 --- /dev/null +++ b/uitest/src/test/java/Enums/TestResultStatus.java @@ -0,0 +1,8 @@ +package Enums; + +public enum TestResultStatus { + SUCCESSFUL, + ABORTED, + FAILED, + DISABLED +} diff --git a/uitest/src/test/java/Helpers/Config.java b/uitest/src/test/java/Helpers/Config.java new file mode 100644 index 00000000..22fd7fe3 --- /dev/null +++ b/uitest/src/test/java/Helpers/Config.java @@ -0,0 +1,117 @@ +package Helpers; + +public class Config { + private String noReset; + private String platform; + private String deviceName; + private String appiumServer; + + // android + public String androidVersion; + public String apkPath; + + // iOS + private String iOSversion; + private String appPath; + private String udid; + private String bundleid; + private String xcodeOrgId; + private String updatedWDABundleId; + + public String getAndroidVersion() { + return androidVersion; + } + + public void setAndroidVersion(String androidVersion) { + this.androidVersion = androidVersion; + } + + public String getApkPath() { + return apkPath; + } + + public void setApkPath(String apkPath) { + this.apkPath = apkPath; + } + + public String getUdid() { + return udid; + } + + public void setUdid(String udid) { + this.udid = udid; + } + + public String getBundleid() { + return bundleid; + } + + public void setBundleid(String bundleid) { + this.bundleid = bundleid; + } + + public String getXcodeOrgId() { + return xcodeOrgId; + } + + public void setXcodeOrgId(String xcodeOrgId) { + this.xcodeOrgId = xcodeOrgId; + } + + public String getUpdatedWDABundleId() { + return updatedWDABundleId; + } + + public void setUpdatedWDABundleId(String updatedWDABundleId) { + this.updatedWDABundleId = updatedWDABundleId; + } + + + public String getiOSversion() { + return iOSversion; + } + + public void setiOSversion(String iOSversion) { + this.iOSversion = iOSversion; + } + + public String getAppPath() { + return appPath; + } + + public void setAppPath(String appPath) { + this.appPath = appPath; + } + + public String getNoReset() { + return noReset; + } + + public void setNoReset(String noReset) { + this.noReset = noReset; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getAppiumServer() { + return appiumServer; + } + + public void setAppiumServer(String appiumServer) { + this.appiumServer = appiumServer; + } + + public String getDeviceName() { + return deviceName; + } + + public void setDeviceName(String deviceName) { + this.deviceName = deviceName; + } +} diff --git a/uitest/src/test/java/Helpers/Log.java b/uitest/src/test/java/Helpers/Log.java new file mode 100644 index 00000000..5f9eaa40 --- /dev/null +++ b/uitest/src/test/java/Helpers/Log.java @@ -0,0 +1,44 @@ +package Helpers; + +import org.apache.log4j.Logger; + +public class Log { + private static Logger Log = Logger.getLogger(Log.class.getName()); + + public static void startLog(String testClassName) { + Log.info("Test is Starting..."); + } + + public static void endLog(String testClassName) { + Log.info("Test is Ending..."); + } + + public static void info(String message) { + System.out.println(); + System.out.println(message); + System.out.println(); + Log.info(message); + } + + public static void warn(String message) { + Log.warn(message); + } + + public static void error(String message) { + System.out.println(); + System.err.println(message); + System.out.println(); + Log.error(message); + } + + public static void fatal(String message) { + Log.fatal(message); + } + + public static void debug(String message) { + System.out.println(); + System.err.println(message); + System.out.println(); + Log.debug(message); + } +} diff --git a/uitest/src/test/java/Helpers/SleepExtension.java b/uitest/src/test/java/Helpers/SleepExtension.java new file mode 100644 index 00000000..2a44a16a --- /dev/null +++ b/uitest/src/test/java/Helpers/SleepExtension.java @@ -0,0 +1,12 @@ +package Helpers; + +public class SleepExtension { + public static void sleep(int time) { // ToDo check static + try { + Thread.sleep(time); + } catch (InterruptedException e) { + Log.error(e.toString()); + } + Log.info("Wait - " + time); + } +} diff --git a/uitest/src/test/java/Helpers/TakeScreenExtension.java b/uitest/src/test/java/Helpers/TakeScreenExtension.java new file mode 100644 index 00000000..4ce93031 --- /dev/null +++ b/uitest/src/test/java/Helpers/TakeScreenExtension.java @@ -0,0 +1,60 @@ +package Helpers; + +import io.qameta.allure.Attachment; +import org.apache.commons.io.FileUtils; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.WebDriver; + +import java.io.File; +import java.io.IOException; + +public class TakeScreenExtension { + private static int screenshotIndex; + private static String testScreenPath; + + public void InitPath(String testName) { + Log.info(testName + " - starting"); + + screenshotIndex = 0; + + setTestScreenPath(testName); + + cleanScreenFolder(); + } + + // ToDo ref - add base artefacts path const + private void setTestScreenPath(String testName) { + String projectPath = System.getProperty("user.dir"); + testScreenPath = projectPath + "/test_artefacts/" + testName + "/screenshots"; + } + + private void cleanScreenFolder() { + try { + File file = new File(testScreenPath); + if (file.exists()) + FileUtils.forceDelete(file); + } catch (IOException e) { + Log.error(e.toString()); + } + } + + @Attachment("{screenshotName}") + public byte[] takeScreenshot(WebDriver driver, String screenshotName) { + try { + File scrFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); + + FileUtils.copyFile(scrFile, new File(testScreenPath + "/" + + screenshotIndex + "-" + screenshotName + ".png"), true); + screenshotIndex++; + + Log.info("TakeScreenshot" + screenshotName); + return ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES); + } catch (Exception e) { + Log.error("Fail TakeScreenshot - " + screenshotName); + Log.error(e.toString()); + } + + return null; + } +} diff --git a/uitest/src/test/java/Helpers/TestResultLoggerExtension.java b/uitest/src/test/java/Helpers/TestResultLoggerExtension.java new file mode 100644 index 00000000..9071f219 --- /dev/null +++ b/uitest/src/test/java/Helpers/TestResultLoggerExtension.java @@ -0,0 +1,34 @@ +package Helpers; + +import Enums.TestResultStatus; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestWatcher; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class TestResultLoggerExtension implements TestWatcher, AfterAllCallback { + private List testResultsStatus = new ArrayList<>(); + + @Override + public void afterAll(ExtensionContext extensionContext) { + } + + @Override + public void testDisabled(ExtensionContext extensionContext, Optional optional) { + } + + @Override + public void testSuccessful(ExtensionContext extensionContext) { + } + + @Override + public void testAborted(ExtensionContext extensionContext, Throwable throwable) { + } + + @Override + public void testFailed(ExtensionContext extensionContext, Throwable throwable) { + } +} \ No newline at end of file diff --git a/uitest/src/test/java/Pages/AccountAdressPage.java b/uitest/src/test/java/Pages/AccountAdressPage.java new file mode 100644 index 00000000..68b621ab --- /dev/null +++ b/uitest/src/test/java/Pages/AccountAdressPage.java @@ -0,0 +1,22 @@ +package Pages; + +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class AccountAdressPage extends BasePage { + public AccountAdressPage(AppiumDriver driver) { + super(driver); + } + + public AccountAdressPage deleteAccount() { + sleep(3000); + tapByClassAndText("android.widget.Button", "ellipsis vertical"); + sleep(3000); + tapByClassAndText("android.widget.Button", "Delete trash"); + sleep(3000); + tapByClassAndText("android.widget.Button", "DELETE"); + + return this; + } + +} diff --git a/uitest/src/test/java/Pages/AddAccountPage.java b/uitest/src/test/java/Pages/AddAccountPage.java new file mode 100644 index 00000000..2a78df06 --- /dev/null +++ b/uitest/src/test/java/Pages/AddAccountPage.java @@ -0,0 +1,31 @@ +package Pages; + +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class AddAccountPage extends BasePage { + public AddAccountPage(AppiumDriver driver) { + super(driver); + } + + public AddAccountPage tapCreate() { + sleep(3000); + tapByClassAndText("android.widget.Button", "CREATE"); + + return this; + } + + public AddAccountPage tapBitcoin() { + sleep(3000); + tapByClassAndText("android.view.View", "Bitcoin BTC"); + + return this; + } + + public AddAccountPage tapAuthenticate() { + sleep(5000); + tapByClassAndText("android.widget.Button", "AUTHENTICATE"); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/BasePage.java b/uitest/src/test/java/Pages/BasePage.java new file mode 100644 index 00000000..82402030 --- /dev/null +++ b/uitest/src/test/java/Pages/BasePage.java @@ -0,0 +1,275 @@ +package Pages; + +import Helpers.Log; +import Helpers.TakeScreenExtension; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileBy; +import io.appium.java_client.MobileElement; +import io.appium.java_client.TouchAction; +import io.appium.java_client.touch.offset.PointOption; +import org.openqa.selenium.By; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; + +import static io.appium.java_client.touch.TapOptions.tapOptions; +import static io.appium.java_client.touch.WaitOptions.waitOptions; +import static io.appium.java_client.touch.offset.ElementOption.element; +import static io.appium.java_client.touch.offset.PointOption.point; +import static java.time.Duration.ofMillis; + +public abstract class BasePage { + protected AppiumDriver driver; + protected WebDriverWait wait; + protected TakeScreenExtension ts; + + public BasePage(AppiumDriver driver) { + this.driver = driver; + wait = new WebDriverWait(driver, 30); + ts = new TakeScreenExtension(); + Log.info("(Page loaded) Object of Page " + this.getClass().getSimpleName() + " created"); + } + + protected MobileElement waitClickable(By element) { + return (MobileElement) wait.until(ExpectedConditions.elementToBeClickable(element)); + } + + protected MobileElement waitPresence(By element) { + return (MobileElement) wait.until(ExpectedConditions.presenceOfElementLocated(element)); + } + + protected void tap(By element) { + waitClickable(element).click(); + } + + protected void tapOnCenterOfElement(MobileElement element) { + new TouchAction(driver).tap(point(element.getLocation())).perform(); + } + + protected void tapByClassAndText(String className, String text) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + Log.debug(this.driver.getPageSource()); + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + //System.out.println(elList.get(i).getText().toLowerCase().equals(text.toLowerCase())); + Log.debug(elList.get(i).getText().toLowerCase()); + + if (elList.get(i).getText().toLowerCase().equals(text.toLowerCase())) { + elList.get(i).click(); + Log.debug("MATCH"); + return; + } else { + Log.debug("'"+elList.get(i).getText().toLowerCase()+"' NO MATCH '"+text.toLowerCase()+"'"); + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + } + protected void tapByClassAndTextRemovedSpaces(String className, String text) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText() + .toLowerCase() + .replaceAll(" ", "") + .equals(text + .toLowerCase() + .replaceAll(" ", ""))) { + elList.get(i).click(); + + return; + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + } + + protected void tapByClassAndContainsText(String className, String text) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText().toLowerCase().contains(text.toLowerCase())) { + elList.get(i).click(); + return; + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + } + + protected void noWaitTapByClassAndText(String className, String text) { + By el = MobileBy.className(className); + + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText().toLowerCase().equals(text.toLowerCase())) { + elList.get(i).click(); + return; + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + } + + protected MobileElement getElementByClassAndText(String className, String text) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText().toLowerCase().equals(text.toLowerCase())) { + return elList.get(i); + } + } catch (Exception e) { + Log.error("getElementByClassAndText - null"); + Log.error(e.toString()); + } + } + + return null; + } + + protected MobileElement getElementByClassAndContainsText(String className, String text) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + Log.debug(this.driver.getPageSource()); + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText().toLowerCase().contains(text.toLowerCase())) { + return elList.get(i); + } + } catch (Exception e) { + Log.error("getElementByClassAndText - null"); + Log.error(e.toString()); + } + } + + return null; + } + + protected List getElementsByClassAndText(String className, String text) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + + List elList = driver.findElements(el); + List resultList = new ArrayList(); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText().toLowerCase().equals(text.toLowerCase())) { + resultList.add(elList.get(i)); + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + + return resultList; + } + + protected void enterByClassAndText(String className, String text, String inputText) { + By el = MobileBy.className(className); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + + List elList = driver.findElements(el); + for (int i = 0; i <= elList.size() - 1; i++) { + try { + if (elList.get(i).getText().toLowerCase().equals(text.toLowerCase())) { + elList.get(i).setValue(inputText); + return; + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + } + + protected void enter(By element, String text) { + MobileElement el = waitClickable(element); + el.click(); + el.clear(); + el.sendKeys(text); + } + + protected void sleep(int time) { + try { + Thread.sleep(time); + } catch (InterruptedException e) { + Log.error(e.toString()); + e.printStackTrace(); + } + Log.info("Wait - " + time); + } + + protected void scrollDown() { + Dimension size = driver.manage().window().getSize(); + int starty = (int) (size.height * 0.80); + int endy = (int) (size.height * 0.20); + int startx = size.width / 2; + System.out.println("starty = " + starty + " ,endy = " + endy + " , startx = " + startx); + + + TouchAction actions = new TouchAction(driver); + actions.press(new PointOption().withCoordinates(startx, starty)) + .waitAction(waitOptions(Duration.ofSeconds(2))) + .moveTo(new PointOption().withCoordinates(startx, endy)) + .release() + .perform(); + } + + public void scrollUsingTouchActionsByElements(MobileElement startElement, MobileElement endElement) { + TouchAction actions = new TouchAction(driver); + actions.press(new PointOption().withCoordinates(startElement.getLocation())) + .waitAction(waitOptions(Duration.ofSeconds(2))) + .moveTo(new PointOption().withCoordinates(endElement.getLocation())) + .release() + .perform(); + } + + protected MobileElement scrollToElement(By by, int miliseconds) { + MobileElement element = null; + int numberOfTimes = 10; + Dimension size = driver.manage().window().getSize(); + int anchor = size.width / 2; + //Swipe up to scroll down + int startPoint = size.height - 200; + int endPoint = 50; + + for (int i = 0; i < numberOfTimes; i++) { + try { + new TouchAction(driver) + .longPress(point(anchor, startPoint)) + .press(point(anchor, startPoint)) //if used press need proper waiting time + .waitAction(waitOptions(ofMillis(miliseconds))) + .moveTo(point(anchor, endPoint)) + .release() + .perform(); + element = driver.findElement(by); + i = numberOfTimes; + } catch (NoSuchElementException ex) { + Log.warn(String.format("Element not available. Scrolling (%s) times…", i + 1)); + } + } + + return element; + } +} diff --git a/uitest/src/test/java/Pages/MainPage.java b/uitest/src/test/java/Pages/MainPage.java new file mode 100644 index 00000000..a8b776ae --- /dev/null +++ b/uitest/src/test/java/Pages/MainPage.java @@ -0,0 +1,40 @@ +package Pages; + +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class MainPage extends BasePage { + public MainPage(AppiumDriver driver) { + super(driver); + } + + public MainPage tapSettings() { + sleep(3000); + tapByClassAndContainsText("android.view.View", "Settings"); + + return this; + } + + public MainPage tapAddAccount() { + sleep(3000); + tapByClassAndContainsText("android.widget.Button", "add add account"); + + return this; + } + + public MainPage tapAeternity() { + sleep(3000); + tapByClassAndContainsText("android.view.View", "æternity AE "); + + return this; + } + + public String getAdress() { + sleep(3000); + MobileElement aeternityElement = getElementByClassAndContainsText("android.view.View", "ak_"); + String address = aeternityElement.getText(); + System.out.println(address); + + return address; + } +} diff --git a/uitest/src/test/java/Pages/SaveSecretPage.java b/uitest/src/test/java/Pages/SaveSecretPage.java new file mode 100644 index 00000000..5c8c40e8 --- /dev/null +++ b/uitest/src/test/java/Pages/SaveSecretPage.java @@ -0,0 +1,57 @@ +package Pages; + +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; +import org.openqa.selenium.By; + +public class SaveSecretPage extends BasePage { + public SaveSecretPage(AppiumDriver driver) { + super(driver); + } + + public SaveSecretPage enterLabel(String label) { + sleep(3000); + By labelEntry = By.className("android.widget.EditText"); + tap(labelEntry); + enter(labelEntry, label); + + return this; + } + + public SaveSecretPage tapConfirm() { + sleep(3000); + tapByClassAndText("android.widget.Button", "CONFIRM"); + + return this; + } + + public SaveSecretPage enablePasscode() { + sleep(3000); + tapByClassAndContainsText("android.widget.CheckBox", "passcode"); + + return this; + } + + public SaveSecretPage enterPasswordAndConfirm(String password) { + sleep(3000); + By passwordEntry = By.id("it.airgap.vault:id/password"); + enter(passwordEntry, password); + By passwordConfirmEntry = By.id("it.airgap.vault:id/password_confirmation"); + enter(passwordConfirmEntry, password); + sleep(3000); + tapByClassAndText("android.widget.Button", "SET PASSWORD"); + + + return this; + } + + public SaveSecretPage enterPassword(String password) { + sleep(3000); + By passwordEntry = By.id("it.airgap.vault:id/password"); + enter(passwordEntry, password); + sleep(3000); + tapByClassAndText("android.widget.Button", "UNLOCK"); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Settings/EditSecretPage.java b/uitest/src/test/java/Pages/Settings/EditSecretPage.java new file mode 100644 index 00000000..f2b1756d --- /dev/null +++ b/uitest/src/test/java/Pages/Settings/EditSecretPage.java @@ -0,0 +1,18 @@ +package Pages.Settings; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class EditSecretPage extends BasePage { + public EditSecretPage(AppiumDriver driver) { + super(driver); + } + + public EditSecretPage tapSocialRecovery() { + sleep(3000); + tapByClassAndContainsText("android.view.View", "Social Recovery"); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Settings/SettingPage.java b/uitest/src/test/java/Pages/Settings/SettingPage.java new file mode 100644 index 00000000..b46ac234 --- /dev/null +++ b/uitest/src/test/java/Pages/Settings/SettingPage.java @@ -0,0 +1,27 @@ +package Pages.Settings; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class SettingPage extends BasePage { + public SettingPage(AppiumDriver driver) { + super(driver); + } + + + + public SettingPage tapAddSecret() { + sleep(3000); + tapByClassAndText("android.widget.Button", "add ADD SECRET"); + + return this; + } + + public SettingPage tapSecret(String secret) { + sleep(3000); + tapByClassAndText("android.view.View", secret); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Settings/SocialRevoveryPage.java b/uitest/src/test/java/Pages/Settings/SocialRevoveryPage.java new file mode 100644 index 00000000..fb5f817c --- /dev/null +++ b/uitest/src/test/java/Pages/Settings/SocialRevoveryPage.java @@ -0,0 +1,40 @@ +package Pages.Settings; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class SocialRevoveryPage extends BasePage { + public SocialRevoveryPage(AppiumDriver driver) { + super(driver); + } + + + public SocialRevoveryPage tapStart() { + sleep(3000); + tapByClassAndContainsText("android.widget.Button", "START"); + + return this; + } + + public SocialRevoveryPage tapNext() { + sleep(3000); + tapByClassAndContainsText("android.widget.Button", "NEXT"); + + return this; + } + + public SocialRevoveryPage tapContinue() { + sleep(3000); + tapByClassAndContainsText("android.widget.Button", "CONTINUE"); + + return this; + } + + public SocialRevoveryPage tapNumberOfShare(String number) { + sleep(3000); + tapByClassAndContainsText("android.widget.Button", number); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Welcome/DisclaimerPage.java b/uitest/src/test/java/Pages/Welcome/DisclaimerPage.java new file mode 100644 index 00000000..6eab99d3 --- /dev/null +++ b/uitest/src/test/java/Pages/Welcome/DisclaimerPage.java @@ -0,0 +1,18 @@ +package Pages.Welcome; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class DisclaimerPage extends BasePage { + public DisclaimerPage(AppiumDriver driver) { + super(driver); + } + + public DisclaimerPage tapUnderstand() { + sleep(10000); + tapByClassAndContainsText("android.widget.Button", "I UNDERSTAND AND ACCEPT"); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Welcome/GenerateSecretPage.java b/uitest/src/test/java/Pages/Welcome/GenerateSecretPage.java new file mode 100644 index 00000000..434ed3fd --- /dev/null +++ b/uitest/src/test/java/Pages/Welcome/GenerateSecretPage.java @@ -0,0 +1,110 @@ +package Pages.Welcome; + +import Helpers.Log; +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileBy; +import io.appium.java_client.MobileElement; +import io.appium.java_client.TouchAction; +import io.appium.java_client.touch.offset.PointOption; +import org.openqa.selenium.By; +import org.openqa.selenium.support.ui.ExpectedConditions; + +import java.util.List; + +import static io.appium.java_client.touch.WaitOptions.waitOptions; +import static java.time.Duration.ofSeconds; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +public class GenerateSecretPage extends BasePage { + public GenerateSecretPage(AppiumDriver driver) { + super(driver); + } + + public GenerateSecretPage drawAround() { + sleep(5000); + MobileElement drawLabel = getElementByClassAndText("android.view.View", "Draw around with your finger."); + int startX = drawLabel.getCenter().x; + int startY = drawLabel.getCenter().y; + + for (int i = 0; i <= 35; i++) { + new TouchAction<>(driver).press(PointOption.point(startX, startY)) + .waitAction(waitOptions(ofSeconds(2))) + .moveTo(PointOption.point(startX + 100, startY + 5)) + .release() + .perform(); + i++; + } + + return this; + } + + public GenerateSecretPage tapContinue() { + sleep(3000); + tapByClassAndText("android.widget.Button", "CONTINUE"); + + return this; + } + + public GenerateSecretPage tapUnderstood() { + sleep(3000); + tapByClassAndText("android.widget.Button", "UNDERSTOOD"); + + return this; + } + + public GenerateSecretPage tapNextStep() { + sleep(3000); + tapByClassAndText("android.widget.Button", "NEXT STEP"); + + return this; + } + + public String getRecoveryPhrase() { + sleep(3000); + + List allView = driver.findElements(By.className("android.view.View")); + String recoveryPhrase = null; + + for(int i = 0; i <= allView.size() - 1; i++) { + if(allView.get(i).getText().contains("Write down each word on a piece of paper")) { + recoveryPhrase= allView.get(i + 1).getText(); // get next view with phrase + } + } + + if(recoveryPhrase == null){ + fail(); + } + + return recoveryPhrase; + } + + public GenerateSecretPage verifyRecoveryPhrase(String phrase) { + sleep(5000); + Log.debug(phrase); + String[] phraseList = phrase.split(" "); + for (int i = 0; i <= phraseList.length - 1; i++) { + + By el = MobileBy.className("android.widget.Button"); + wait.until(ExpectedConditions.presenceOfElementLocated(el)); + + List elList = driver.findElements(el); + for (int j = elList.size() - 1; j >= 0; j--) { + try { + Log.debug(elList.get(j).getText().toLowerCase()); + + if (elList.get(j).getText().toLowerCase().equals(phraseList[i].toLowerCase())) { + elList.get(j).click(); + break; + } + } catch (Exception e) { + Log.error(e.toString()); + } + } + sleep(2000); + } + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Welcome/ImportPage.java b/uitest/src/test/java/Pages/Welcome/ImportPage.java new file mode 100644 index 00000000..ff862645 --- /dev/null +++ b/uitest/src/test/java/Pages/Welcome/ImportPage.java @@ -0,0 +1,27 @@ +package Pages.Welcome; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; +import org.openqa.selenium.By; + +public class ImportPage extends BasePage { + public ImportPage(AppiumDriver driver) { + super(driver); + } + + public ImportPage enterSecretPhrase(String phrase) { + sleep(3000); + By phraseEntry = By.className("android.widget.EditText"); + enter(phraseEntry, phrase); + + return this; + } + + public ImportPage tapImport() { + sleep(3000); + tapByClassAndText("android.widget.Button", "IMPORT"); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Welcome/SelectSecurityPage.java b/uitest/src/test/java/Pages/Welcome/SelectSecurityPage.java new file mode 100644 index 00000000..5763da53 --- /dev/null +++ b/uitest/src/test/java/Pages/Welcome/SelectSecurityPage.java @@ -0,0 +1,18 @@ +package Pages.Welcome; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; + +public class SelectSecurityPage extends BasePage { + public SelectSecurityPage(AppiumDriver driver) { + super(driver); + } + + public SelectSecurityPage tapLetsGo() { + sleep(3000); + tapByClassAndText("android.widget.Button", "LET'S GO"); + + return this; + } +} diff --git a/uitest/src/test/java/Pages/Welcome/SetUpPage.java b/uitest/src/test/java/Pages/Welcome/SetUpPage.java new file mode 100644 index 00000000..42b27026 --- /dev/null +++ b/uitest/src/test/java/Pages/Welcome/SetUpPage.java @@ -0,0 +1,48 @@ +package Pages.Welcome; + +import Pages.BasePage; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; +import org.openqa.selenium.By; + +public class SetUpPage extends BasePage { + public SetUpPage(AppiumDriver driver) { + super(driver); + } + + public SetUpPage tapGenerate() { + sleep(5000); +// tapByClassAndText("android.widget.Button", "GENERATE "); +// driver.findElement(By.name("GENERATE ")); + tapByClassAndText("android.widget.Button", "GENERATE"); + + return this; + } + + public SetUpPage tapImport() { + sleep(3000); + tapByClassAndText("android.widget.Button", "IMPORT"); + + return this; + } + + public SetUpPage tapGrantPermission() { + sleep(2000); + tapByClassAndText("android.widget.Button", "GRANT PERMISSION"); + sleep(5000); + tapByClassAndText("android.widget.Button", "Deny"); + sleep(5000); + tapByClassAndText("android.widget.Button", "Deny"); + + return this; + } + + public SetUpPage generateSecret() { + sleep(3000); + By drawing_frame = By.className("android.widget.Image"); + + tapByClassAndText("android.widget.Button", "CONTINUE"); + + return this; + } +} diff --git a/uitest/src/test/java/TestFixtures/BaseTestFixture.java b/uitest/src/test/java/TestFixtures/BaseTestFixture.java new file mode 100644 index 00000000..08274b57 --- /dev/null +++ b/uitest/src/test/java/TestFixtures/BaseTestFixture.java @@ -0,0 +1,221 @@ +package TestFixtures; + +import Helpers.Config; +import Helpers.Log; +import Helpers.SleepExtension; +import Helpers.TakeScreenExtension; +import Pages.*; +import Pages.Welcome.DisclaimerPage; +import Pages.Welcome.GenerateSecretPage; +import Pages.Welcome.SelectSecurityPage; +import Pages.Welcome.SetUpPage; +import com.google.common.collect.Lists; +import io.appium.java_client.AppiumDriver; +import io.appium.java_client.MobileElement; +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.ios.IOSDriver; +import io.appium.java_client.remote.MobileCapabilityType; +import org.apache.http.ParseException; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.openqa.selenium.remote.DesiredCapabilities; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + + +public abstract class BaseTestFixture { + protected AppiumDriver driver; + + public static boolean isNullOrEmpty(String str) { + return str == null || str.isEmpty(); + } + + @BeforeEach + public void setUp(TestInfo testInfo) { + Log.startLog("setUp"); + + new TakeScreenExtension().InitPath(testInfo.getDisplayName()); + + Config config = setProjectConfig("config"); + assert config != null; + initMobileDriver(config); + } + + @AfterEach + public void cleanUp() { + try { + String pageSource = driver.getPageSource(); + // driver.quit(); + } catch (Exception e) { + Log.error(e.toString()); + } + + Log.endLog("cleanUp"); + } + + protected String generateSecret(String secretName, boolean enablePasscode) { + new DisclaimerPage(driver) + .tapUnderstand(); + new SelectSecurityPage(driver) + .tapLetsGo(); + + new SetUpPage(driver) + .tapGenerate() + .tapGrantPermission(); + enterPIN(); + SleepExtension.sleep(5000); + new GenerateSecretPage(driver) + .drawAround() + .tapContinue() + .tapUnderstood(); + SleepExtension.sleep(30000); + String recoveryPhrase = new GenerateSecretPage(driver).getRecoveryPhrase(); + System.out.println(recoveryPhrase); + new GenerateSecretPage(driver) + .tapNextStep() + .verifyRecoveryPhrase(recoveryPhrase) + .tapContinue(); + new SaveSecretPage(driver) + .enterLabel(secretName); + String password = "1q2w3e"; + if(enablePasscode) { + new SaveSecretPage(driver) + .enablePasscode(); + } + new SaveSecretPage(driver) + .tapConfirm(); + if(enablePasscode) { + + new SaveSecretPage(driver) + .enterPasswordAndConfirm(password) + .enterPassword(password); + } + + enterPIN(); + + new AddAccountPage(driver) + .tapCreate(); + + new AddAccountPage(driver) + .tapAuthenticate(); + + if(enablePasscode) { + new SaveSecretPage(driver) + .enterPassword(password); + } + enterPIN(); + + return recoveryPhrase; + } + + + protected void enterPIN() { + SleepExtension.sleep(5000); + Map input_text = new HashMap<>(); + input_text.put("command", "input"); + input_text.put("args", Lists.newArrayList("text", "1235")); + driver.executeScript("mobile: shell", input_text); + SleepExtension.sleep(2000); + Map input_enter = new HashMap<>(); + input_enter.put("command", "input"); + input_enter.put("args", Lists.newArrayList("keyevent", "KEYCODE_ENTER")); + driver.executeScript("mobile: shell", input_enter); + SleepExtension.sleep(5000); + } + + private Config setProjectConfig(String configName) { + try { + String projectPath = System.getProperty("user.dir"); + String content = new String(Files.readAllBytes(Paths.get(projectPath + "//" + configName + ".json"))); + + JSONObject jsonFile = new JSONObject(content); + + Config currentConfig = new Config(); + currentConfig.setPlatform(jsonFile.getString("platform")); + currentConfig.setNoReset(jsonFile.getString("noReset")); + currentConfig.setAppiumServer(jsonFile.getString("appiumServer")); + + // android + currentConfig.setAndroidVersion(jsonFile.getString("androidVersion")); + currentConfig.setApkPath(jsonFile.getString("apkPath")); + + // ios + currentConfig.setiOSversion(jsonFile.getString("iOSversion")); + currentConfig.setAppPath(jsonFile.getString("appPath")); + currentConfig.setDeviceName(jsonFile.getString("deviceName")); + + return currentConfig; + + } catch (IOException | ParseException e) { + Log.error(e.toString()); + } + + return null; + } + + private void initMobileDriver(Config config) { + DesiredCapabilities capabilities = new DesiredCapabilities(); + URL driverUrl; + + try { + driverUrl = new URL(config.getAppiumServer()); + } catch (MalformedURLException e) { + Log.error(e.toString()); + throw new RuntimeException(e); + } + + // important + capabilities.setCapability(MobileCapabilityType.PLATFORM_NAME, config.getPlatform()); + if(config.getPlatform().equals("iOS")) { + capabilities.setCapability(MobileCapabilityType.APP, config.getAppPath()); + capabilities.setCapability(MobileCapabilityType.PLATFORM_VERSION, config.getiOSversion()); + capabilities.setCapability(MobileCapabilityType.DEVICE_NAME, config.getDeviceName()); + capabilities.setCapability(MobileCapabilityType.AUTOMATION_NAME, "XCUITest"); + } else if(config.getPlatform().equals("android")) { + capabilities.setCapability(MobileCapabilityType.APP, config.getApkPath()); + capabilities.setCapability(MobileCapabilityType.PLATFORM_VERSION, config.getAndroidVersion()); +// capabilities.setCapability("appPackage", "it.airgap.vault"); +// capabilities.setCapability("appActivity", "it.airgap.vault.MainActivity"); + capabilities.setCapability(MobileCapabilityType.DEVICE_NAME, "default"); + capabilities.setCapability(MobileCapabilityType.PLATFORM_NAME, "android"); +// capabilities.setCapability("ignoreUnimportantViews", true); +// capabilities.setCapability("disableAndroidWatchers", true); + capabilities.setCapability("newCommandTimeout", 10000); +// capabilities.setCapability("androidInstallTimeout", 150000); + capabilities.setCapability(MobileCapabilityType.AUTOMATION_NAME, "UiAutomator2"); + capabilities.setCapability("skipUnlock", "true"); + } + + // important for run on real device + if(config.getPlatform().equals("iOS")) { + capabilities.setCapability("udid", "auto"); + capabilities.setCapability("xcodeSigningId", "iPhone Developer"); + if (isNullOrEmpty(config.getBundleid())) + capabilities.setCapability("bundleId", config.getBundleid()); + if (isNullOrEmpty(config.getXcodeOrgId())) + capabilities.setCapability("xcodeOrgId", config.getXcodeOrgId()); + if (isNullOrEmpty(config.getUpdatedWDABundleId())) + capabilities.setCapability("updatedWDABundleId", config.getUpdatedWDABundleId()); + } + // optional +// capabilities.setCapability("autoGrantPermissions", true); +// capabilities.setCapability("autoAcceptAlerts", true); + capabilities.setCapability("unicodeKeyboard", true); + capabilities.setCapability("resetKeyboard", true); + capabilities.setCapability(MobileCapabilityType.NO_RESET, config.getNoReset()); + + if(config.getPlatform().equals("iOS")) { + driver = new IOSDriver<>(driverUrl, capabilities); + } else { + driver = new AndroidDriver<>(driverUrl, capabilities); + } + } +} diff --git a/uitest/src/test/java/Tests/GenerateSecretTest.java b/uitest/src/test/java/Tests/GenerateSecretTest.java new file mode 100644 index 00000000..21d91b04 --- /dev/null +++ b/uitest/src/test/java/Tests/GenerateSecretTest.java @@ -0,0 +1,13 @@ +package Tests; + +import TestFixtures.BaseTestFixture; +import org.junit.jupiter.api.Test; + +public class GenerateSecretTest extends BaseTestFixture { + @Test + public void GenerateSecret() { + generateSecret("test s3", false); + } + + +} diff --git a/uitest/src/test/java/Tests/ImportSecretTest.java b/uitest/src/test/java/Tests/ImportSecretTest.java new file mode 100644 index 00000000..ce9c4e1f --- /dev/null +++ b/uitest/src/test/java/Tests/ImportSecretTest.java @@ -0,0 +1,47 @@ +package Tests; + +import Pages.*; +import Pages.Settings.SettingPage; +import Pages.Welcome.ImportPage; +import Pages.Welcome.SetUpPage; +import TestFixtures.BaseTestFixture; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ImportSecretTest extends BaseTestFixture { + @Test + public void ImportSecret() { + enterPIN(); + + String recoveryPhrase = generateSecret("test s2", false); + + String adress = new MainPage(driver).getAdress(); + new MainPage(driver) + .tapAeternity(); + + new AccountAdressPage(driver) + .deleteAccount(); + + new MainPage(driver) + .tapSettings(); + new SettingPage(driver) + .tapAddSecret(); + new SetUpPage(driver) + .tapImport(); + new ImportPage(driver) + .enterSecretPhrase(recoveryPhrase) + .tapImport(); + new SaveSecretPage(driver) + .enterLabel("test import secret label 1") + .tapConfirm(); + enterPIN(); + new AddAccountPage(driver) + .tapCreate() + .tapAuthenticate(); + + enterPIN(); + + assertEquals(adress, new MainPage(driver).getAdress()); + } +} diff --git a/uitest/src/test/java/Tests/MultiAccountsTest.java b/uitest/src/test/java/Tests/MultiAccountsTest.java new file mode 100644 index 00000000..2e58d163 --- /dev/null +++ b/uitest/src/test/java/Tests/MultiAccountsTest.java @@ -0,0 +1,23 @@ +package Tests; + +import Pages.AddAccountPage; +import Pages.MainPage; +import Pages.Settings.SettingPage; +import TestFixtures.BaseTestFixture; +import org.junit.jupiter.api.Test; + +public class MultiAccountsTest extends BaseTestFixture { + @Test + public void MultiAccountsTest() { + String secretName = "ma tt 112"; + generateSecret(secretName, false); + new MainPage(driver) + .tapAddAccount(); + new AddAccountPage(driver) + .tapBitcoin() + .tapCreate(); + enterPIN(); + new SettingPage(driver) + .tapSecret(secretName); + } +} diff --git a/uitest/src/test/java/Tests/ParanoiaModeTest.java b/uitest/src/test/java/Tests/ParanoiaModeTest.java new file mode 100644 index 00000000..68db86a0 --- /dev/null +++ b/uitest/src/test/java/Tests/ParanoiaModeTest.java @@ -0,0 +1,18 @@ +package Tests; + +import Pages.MainPage; +import Pages.Settings.SettingPage; +import TestFixtures.BaseTestFixture; +import org.junit.jupiter.api.Test; + +public class ParanoiaModeTest extends BaseTestFixture { + @Test + public void ParanoiaMode() { + String secretName = "s tt 112"; + generateSecret(secretName, true); + new MainPage(driver) + .tapSettings(); + new SettingPage(driver) + .tapSecret(secretName); + } +} diff --git a/uitest/src/test/java/Tests/SocialRecoveryTest.java.bak b/uitest/src/test/java/Tests/SocialRecoveryTest.java.bak new file mode 100644 index 00000000..d0864e84 --- /dev/null +++ b/uitest/src/test/java/Tests/SocialRecoveryTest.java.bak @@ -0,0 +1,59 @@ +package Tests; + +import Helpers.SleepExtension; +import Pages.Settings.EditSecretPage; +import Pages.MainPage; +import Pages.Settings.SettingPage; +import Pages.Settings.SocialRevoveryPage; +import Pages.Welcome.GenerateSecretPage; +import TestFixtures.BaseTestFixture; +import org.junit.jupiter.api.Test; +import Helpers.Log; + +public class SocialRecoveryTest extends BaseTestFixture { + @Test + public void SocialRecovery() { + String secretName = "test s1"; + generateSecret(secretName, false); + new MainPage(driver) + .tapSettings(); + new SettingPage(driver) + .tapSecret(secretName); + new EditSecretPage(driver) + .tapSocialRecovery(); + new SocialRevoveryPage(driver) + .tapNumberOfShare("2") + .tapStart(); + enterPIN(); + Log.debug("going for first share"); + String secretShare1 = new GenerateSecretPage(driver).getRecoveryPhrase(); + new SocialRevoveryPage(driver) + .tapNext(); + new GenerateSecretPage(driver) + .verifyRecoveryPhrase(secretShare1); + new SocialRevoveryPage(driver) + .tapNext(); + + Log.debug("going for second share"); + SleepExtension.sleep(10000); + Log.debug(driver.getPageSource()); + String secretShare2 = new GenerateSecretPage(driver).getRecoveryPhrase(); + Log.debug("secretShare2 "+secretShare2); + new SocialRevoveryPage(driver) + .tapNext(); + new GenerateSecretPage(driver) + .verifyRecoveryPhrase(secretShare2); + new SocialRevoveryPage(driver) + .tapContinue(); + + SleepExtension.sleep(10000); + String secretShare3 = new GenerateSecretPage(driver).getRecoveryPhrase(); + new SocialRevoveryPage(driver) + .tapNext(); + new GenerateSecretPage(driver) + .verifyRecoveryPhrase(secretShare3); + new SocialRevoveryPage(driver) + .tapContinue(); + + } +}