├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .flowconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── pr-checks.yml │ └── pr-rebase.yml ├── .gitignore ├── .husky └── pre-commit ├── .mocharc.json ├── .prettierrc.json ├── .travis.yml ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── android ├── build.gradle ├── format-java.sh └── src │ └── main │ ├── AndroidManifest.xml │ ├── cpp │ ├── CMakeLists.txt │ ├── edge-core-jni.c │ └── scrypt │ │ ├── crypto_scrypt.c │ │ ├── crypto_scrypt.h │ │ ├── readme.md │ │ ├── sha256.c │ │ ├── sha256.h │ │ └── sysendian.h │ └── java │ └── app │ └── edge │ └── reactnative │ └── core │ ├── Disklet.java │ ├── EdgeCorePackage.java │ ├── EdgeCoreWebView.java │ ├── EdgeCoreWebViewManager.java │ ├── EdgeNative.java │ ├── PendingCall.java │ └── StreamStringReader.java ├── docs └── key-formats.md ├── edge-core-js.podspec ├── flow-typed └── react-native-libs.js ├── ios ├── Disklet.swift ├── EdgeCoreWebView.swift ├── EdgeCoreWebViewManager.m ├── EdgeCoreWebViewManager.swift ├── EdgeNative.swift ├── PendingCall.swift ├── edge-core-js-Bridging-Header.h └── edge-core-js.xcodeproj │ └── project.pbxproj ├── package.json ├── rollup.config.js ├── scripts └── make-types.ts ├── src ├── browser.ts ├── client-side.ts ├── core │ ├── account │ │ ├── account-api.ts │ │ ├── account-cleaners.ts │ │ ├── account-files.ts │ │ ├── account-init.ts │ │ ├── account-pixie.ts │ │ ├── account-reducer.ts │ │ ├── account-types.ts │ │ ├── custom-tokens.ts │ │ ├── data-store-api.ts │ │ ├── lobby-api.ts │ │ ├── memory-wallet.ts │ │ └── plugin-api.ts │ ├── actions.ts │ ├── context │ │ ├── client-file.ts │ │ ├── context-api.ts │ │ ├── context-pixie.ts │ │ ├── info-cache-file.ts │ │ └── internal-api.ts │ ├── core.ts │ ├── currency │ │ ├── change-server-connection.ts │ │ ├── change-server-protocol.ts │ │ ├── currency-pixie.ts │ │ ├── currency-reducer.ts │ │ ├── currency-selectors.ts │ │ └── wallet │ │ │ ├── currency-wallet-api.ts │ │ │ ├── currency-wallet-callbacks.ts │ │ │ ├── currency-wallet-cleaners.ts │ │ │ ├── currency-wallet-export.ts │ │ │ ├── currency-wallet-files.ts │ │ │ ├── currency-wallet-pixie.ts │ │ │ ├── currency-wallet-reducer.ts │ │ │ ├── enabled-tokens.ts │ │ │ ├── max-spend.ts │ │ │ ├── metadata.ts │ │ │ └── upgrade-memos.ts │ ├── fake │ │ ├── fake-db.ts │ │ ├── fake-io.ts │ │ ├── fake-responses.ts │ │ ├── fake-server.ts │ │ └── fake-world.ts │ ├── log │ │ └── log.ts │ ├── login │ │ ├── airbitz-stashes.ts │ │ ├── create.ts │ │ ├── edge.ts │ │ ├── keys.ts │ │ ├── lobby.ts │ │ ├── login-delete.ts │ │ ├── login-fetch.ts │ │ ├── login-messages.ts │ │ ├── login-reducer.ts │ │ ├── login-secret.ts │ │ ├── login-selectors.ts │ │ ├── login-stash.ts │ │ ├── login-types.ts │ │ ├── login-username.ts │ │ ├── login.ts │ │ ├── otp.ts │ │ ├── password.ts │ │ ├── pin2.ts │ │ ├── recovery2.ts │ │ ├── splitting.ts │ │ ├── storage-keys.ts │ │ └── vouchers.ts │ ├── plugins │ │ ├── plugins-actions.ts │ │ ├── plugins-reducer.ts │ │ └── plugins-selectors.ts │ ├── root-pixie.ts │ ├── root-reducer.ts │ ├── root.ts │ ├── scrypt │ │ ├── scrypt-pixie.ts │ │ └── scrypt-selectors.ts │ ├── storage │ │ ├── encrypt-disklet.ts │ │ ├── repo.ts │ │ ├── storage-actions.ts │ │ ├── storage-api.ts │ │ ├── storage-reducer.ts │ │ └── storage-selectors.ts │ └── swap │ │ └── swap-api.ts ├── globals.d.ts ├── index.ts ├── io │ ├── browser │ │ ├── browser-io.ts │ │ └── fetch-cors-proxy.ts │ ├── hidden-properties.ts │ ├── node │ │ └── node-io.ts │ └── react-native │ │ ├── native-bridge.ts │ │ ├── polyfills.ts │ │ ├── react-native-types.ts │ │ ├── react-native-webview.tsx │ │ ├── react-native-worker.ts │ │ └── yaob-callbacks.ts ├── libs.d.ts ├── react-native.tsx ├── types │ ├── error.ts │ ├── exports.ts │ ├── fake-types.ts │ ├── server-cleaners.ts │ ├── server-types.ts │ ├── type-helpers.ts │ └── types.ts └── util │ ├── asMap.ts │ ├── asyncWaterfall.ts │ ├── compare.ts │ ├── crypto │ ├── crypto.ts │ ├── hashes.ts │ ├── hotp.ts │ ├── scrypt.ts │ └── verify.ts │ ├── encoding.ts │ ├── file-helpers.ts │ ├── json-rpc.ts │ ├── match-json.ts │ ├── periodic-task.ts │ ├── promise.ts │ ├── shuffle.ts │ ├── snooze.ts │ ├── updateQueue.ts │ ├── util.ts │ └── validateServer.ts ├── test ├── core │ ├── account │ │ ├── account.test.ts │ │ └── data-store.test.ts │ ├── context │ │ └── context.test.ts │ ├── currency │ │ ├── confirmations.test.ts │ │ ├── currency.test.ts │ │ └── wallet │ │ │ └── currency-wallet.test.ts │ ├── login │ │ ├── airbitz.test.ts │ │ ├── edge.test.ts │ │ ├── keys.test.ts │ │ ├── lobby.test.ts │ │ ├── login.test.ts │ │ ├── otp.test.ts │ │ └── splitting.test.ts │ ├── plugins │ │ └── plugins.test.ts │ ├── scrypt │ │ └── snrp.test.ts │ ├── storage │ │ └── repo.test.ts │ └── swap.test.ts ├── expect-rejection.ts ├── fake │ ├── fake-broken-engine.ts │ ├── fake-currency-plugin.ts │ ├── fake-plugins.ts │ ├── fake-swap-plugin.ts │ ├── fake-transactions.ts │ └── fake-user.ts ├── setup.test.ts └── util │ ├── asMap.test.ts │ ├── compare.test.ts │ ├── crypto │ ├── crypto.test.ts │ ├── hashes.test.ts │ ├── hotp.test.ts │ └── scrypt.test.ts │ ├── encoding.test.ts │ ├── promise.test.ts │ └── validateServer.test.ts ├── tsconfig.json ├── types.d.ts ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /android/src/main/assets/ 2 | /lib/ 3 | /types.js 4 | /types.js.flow 5 | /types.mjs 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard-kit/prettier", 4 | "standard-kit/prettier/node", 5 | "standard-kit/prettier/jsx", 6 | "standard-kit/prettier/flow", 7 | "standard-kit/prettier/typescript" 8 | ], 9 | "parserOptions": { 10 | "project": "tsconfig.json" 11 | }, 12 | "plugins": [ 13 | "simple-import-sort" 14 | ], 15 | "rules": { 16 | "@typescript-eslint/default-param-last": "off", 17 | "@typescript-eslint/no-invalid-void-type": "off", 18 | "@typescript-eslint/promise-function-async": "off", 19 | "simple-import-sort/imports": "error" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/resolve/.* 3 | .*/node_modules/webpack-cli/.* 4 | 5 | [include] 6 | 7 | [libs] 8 | 9 | [lints] 10 | sketchy-null=error 11 | 12 | [options] 13 | esproposal.optional_chaining=enable 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### CHANGELOG 2 | 3 | Does this branch warrant an entry to the CHANGELOG? 4 | 5 | - [ ] Yes 6 | - [ ] No 7 | 8 | ### Dependencies 9 | 10 | none 11 | 12 | ### Description 13 | 14 | none 15 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | on: [pull_request] 3 | jobs: 4 | block-wip-pr: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2.0.0 8 | - name: Block WIP PR 9 | uses: samholmes/block-wip-pr-action@v1.2.0 10 | -------------------------------------------------------------------------------- /.github/workflows/pr-rebase.yml: -------------------------------------------------------------------------------- 1 | name: PR Rebase 2 | on: 3 | issue_comment: 4 | types: [created] 5 | jobs: 6 | rebase: 7 | name: Rebase 8 | if: >- 9 | github.event.issue.pull_request != '' && 10 | ( 11 | contains(github.event.comment.body, '/autosquash') || 12 | contains(github.event.comment.body, '/fixup') || 13 | contains(github.event.comment.body, '/rebase') 14 | ) 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout the latest code 18 | uses: actions/checkout@v3 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | fetch-depth: 0 # otherwise, you will fail to push refs to dest repo 22 | - name: Automatic Rebase 23 | uses: EdgeApp/rebase@changelog-resolver 24 | with: 25 | autosquash: ${{ true }} 26 | changelogResolver: ${{ true }} 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output: 2 | /android/*.jar 3 | /android/src/main/assets/ 4 | /lib/ 5 | /types.js 6 | /types.js.flow 7 | /types.mjs 8 | 9 | # Package managers: 10 | node_modules/ 11 | npm-debug.log 12 | package-lock.json 13 | yarn-error.log 14 | stats.json 15 | 16 | # Editors: 17 | .DS_Store 18 | .idea/ 19 | .vscode/ 20 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run precommit 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "sucrase/register", 3 | "spec": "test/**/*.test.ts" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14 4 | script: 5 | - 'yarn verify' 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --ignore-scripts true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Airbitz Inc 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms are permitted provided that 5 | the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 3. Redistribution or use of modified source code requires the express written 13 | permission of Airbitz Inc. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | The views and conclusions contained in the software and documentation are those 27 | of the authors and should not be interpreted as representing official policies, 28 | either expressed or implied, of the Airbitz Project. 29 | 30 | 31 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:3.6.0' 9 | } 10 | } 11 | 12 | apply plugin: 'com.android.library' 13 | 14 | def safeExtGet(prop, fallback) { 15 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 16 | } 17 | 18 | def DEFAULT_COMPILE_SDK_VERSION = 28 19 | def DEFAULT_BUILD_TOOLS_VERSION = '28.0.2' 20 | def DEFAULT_MIN_SDK_VERSION = 19 21 | def DEFAULT_TARGET_SDK_VERSION = 27 22 | def DEFAULT_WEBKIT_VERSION = '1.4.0' 23 | 24 | android { 25 | namespace "app.edge.reactnative.core" 26 | compileSdkVersion safeExtGet('compileSdkVersion', DEFAULT_COMPILE_SDK_VERSION) 27 | buildToolsVersion safeExtGet('buildToolsVersion', DEFAULT_BUILD_TOOLS_VERSION) 28 | 29 | defaultConfig { 30 | minSdkVersion safeExtGet('minSdkVersion', DEFAULT_MIN_SDK_VERSION) 31 | targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION) 32 | versionCode 1 33 | versionName '1.0' 34 | } 35 | lintOptions { 36 | abortOnError false 37 | } 38 | externalNativeBuild { 39 | cmake { 40 | path "src/main/cpp/CMakeLists.txt" 41 | } 42 | } 43 | } 44 | 45 | repositories { 46 | } 47 | 48 | def webkit_version = safeExtGet('webkitVersion', DEFAULT_WEBKIT_VERSION) 49 | 50 | dependencies { 51 | implementation "androidx.webkit:webkit:$webkit_version" 52 | implementation 'com.facebook.react:react-native:+' 53 | } 54 | -------------------------------------------------------------------------------- /android/format-java.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | here=$(dirname $0) 4 | 5 | formatJava() { 6 | tool="google-java-format-1.7-all-deps.jar" 7 | url="https://github.com/google/google-java-format/releases/download/google-java-format-1.7/$tool" 8 | jar="$here/$tool" 9 | 10 | # If the tool is missing, grab it from GitHub: 11 | if [ ! -e "$jar" ]; then 12 | curl -L -o "$jar" "$url" 13 | fi 14 | 15 | echo $(find "$here/src" -name "*.java") 16 | java -jar "$jar" --replace $(find "$here/src" -name "*.java") 17 | } 18 | 19 | formatJava 20 | -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /android/src/main/cpp/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.4.1) 2 | project("edge-core-js") 3 | 4 | add_compile_options(-fvisibility=hidden -w) 5 | 6 | include_directories("scrypt/") 7 | 8 | add_library( 9 | edge-core-jni 10 | SHARED 11 | edge-core-jni.c 12 | scrypt/crypto_scrypt.c 13 | scrypt/sha256.c 14 | ) 15 | -------------------------------------------------------------------------------- /android/src/main/cpp/edge-core-jni.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "scrypt/crypto_scrypt.h" 4 | 5 | JNIEXPORT jbyteArray JNICALL 6 | Java_app_edge_reactnative_core_EdgeNative_scrypt( 7 | JNIEnv *env, 8 | jobject self, 9 | jbyteArray data, 10 | jbyteArray salt, 11 | jint n, 12 | jint r, 13 | jint p, 14 | jint dklen 15 | ) { 16 | jsize dataLength = (*env)->GetArrayLength(env, data); 17 | jsize saltLength = (*env)->GetArrayLength(env, salt); 18 | jbyte *pData = alloca(dataLength * sizeof(jbyte)); 19 | jbyte *pSalt = alloca(saltLength * sizeof(jbyte)); 20 | jbyte *pOut = alloca(dklen * sizeof(jbyte)); 21 | jbyteArray out = (*env)->NewByteArray(env, dklen); 22 | if (!out) return NULL; 23 | 24 | (*env)->GetByteArrayRegion(env, data, 0, dataLength, pData); 25 | (*env)->GetByteArrayRegion(env, salt, 0, saltLength, pSalt); 26 | 27 | if (crypto_scrypt( 28 | pData, dataLength, 29 | pSalt, saltLength, 30 | n, r, p, 31 | pOut, dklen 32 | )) return NULL; 33 | 34 | (*env)->SetByteArrayRegion(env, out, 0, dklen, pOut); 35 | return out; 36 | } 37 | -------------------------------------------------------------------------------- /android/src/main/cpp/scrypt/crypto_scrypt.h: -------------------------------------------------------------------------------- 1 | /*- 2 | * Copyright 2009 Colin Percival 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without 6 | * modification, are permitted provided that the following conditions 7 | * are met: 8 | * 1. Redistributions of source code must retain the above copyright 9 | * notice, this list of conditions and the following disclaimer. 10 | * 2. Redistributions in binary form must reproduce the above copyright 11 | * notice, this list of conditions and the following disclaimer in the 12 | * documentation and/or other materials provided with the distribution. 13 | * 14 | * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 15 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE 18 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 20 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 21 | * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 22 | * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 23 | * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 24 | * SUCH DAMAGE. 25 | * 26 | * This file was originally written by Colin Percival as part of the Tarsnap 27 | * online backup system. 28 | */ 29 | #ifndef _CRYPTO_SCRYPT_H_ 30 | #define _CRYPTO_SCRYPT_H_ 31 | 32 | #include 33 | 34 | /** 35 | * crypto_scrypt(passwd, passwdlen, salt, saltlen, N, r, p, buf, buflen): 36 | * Compute scrypt(passwd[0 .. passwdlen - 1], salt[0 .. saltlen - 1], N, r, 37 | * p, buflen) and write the result into buf. The parameters r, p, and buflen 38 | * must satisfy r * p < 2^30 and buflen <= (2^32 - 1) * 32. The parameter N 39 | * must be a power of 2 greater than 1. 40 | * 41 | * Return 0 on success; or -1 on error. 42 | */ 43 | int crypto_scrypt(const uint8_t *, size_t, const uint8_t *, size_t, uint64_t, 44 | uint32_t, uint32_t, uint8_t *, size_t); 45 | 46 | #endif /* !_CRYPTO_SCRYPT_H_ */ 47 | -------------------------------------------------------------------------------- /android/src/main/cpp/scrypt/readme.md: -------------------------------------------------------------------------------- 1 | # scrypt 2 | 3 | This source code has been extracted from the 4 | [scrypt command-line utility](https://www.tarsnap.com/scrypt.html) 5 | and turned into a standalone library. 6 | 7 | The original locations in the scrypt-1.1.6.tgz file are: 8 | 9 | - lib/crypto/crypto_scrypt-ref.c 10 | - lib/crypto/crypto_scrypt.h 11 | - lib/crypto/sha256.c 12 | - lib/crypto/sha256.h 13 | - lib/util/sysendian.h 14 | -------------------------------------------------------------------------------- /android/src/main/cpp/scrypt/sha256.h: -------------------------------------------------------------------------------- 1 | /*- 2 | * Copyright 2005,2007,2009 Colin Percival 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without 6 | * modification, are permitted provided that the following conditions 7 | * are met: 8 | * 1. Redistributions of source code must retain the above copyright 9 | * notice, this list of conditions and the following disclaimer. 10 | * 2. Redistributions in binary form must reproduce the above copyright 11 | * notice, this list of conditions and the following disclaimer in the 12 | * documentation and/or other materials provided with the distribution. 13 | * 14 | * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND 15 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 17 | * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE 18 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 20 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 21 | * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 22 | * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 23 | * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 24 | * SUCH DAMAGE. 25 | * 26 | * $FreeBSD: src/lib/libmd/sha256.h,v 1.2 2006/01/17 15:35:56 phk Exp $ 27 | */ 28 | 29 | #ifndef _SHA256_H_ 30 | #define _SHA256_H_ 31 | 32 | #include 33 | 34 | #include 35 | 36 | typedef struct SHA256Context { 37 | uint32_t state[8]; 38 | uint32_t count[2]; 39 | unsigned char buf[64]; 40 | } SHA256_CTX; 41 | 42 | typedef struct HMAC_SHA256Context { 43 | SHA256_CTX ictx; 44 | SHA256_CTX octx; 45 | } HMAC_SHA256_CTX; 46 | 47 | void SHA256_Init(SHA256_CTX *); 48 | void SHA256_Update(SHA256_CTX *, const void *, size_t); 49 | void SHA256_Final(unsigned char [32], SHA256_CTX *); 50 | void HMAC_SHA256_Init(HMAC_SHA256_CTX *, const void *, size_t); 51 | void HMAC_SHA256_Update(HMAC_SHA256_CTX *, const void *, size_t); 52 | void HMAC_SHA256_Final(unsigned char [32], HMAC_SHA256_CTX *); 53 | 54 | /** 55 | * PBKDF2_SHA256(passwd, passwdlen, salt, saltlen, c, buf, dkLen): 56 | * Compute PBKDF2(passwd, salt, c, dkLen) using HMAC-SHA256 as the PRF, and 57 | * write the output to buf. The value dkLen must be at most 32 * (2^32 - 1). 58 | */ 59 | void PBKDF2_SHA256(const uint8_t *, size_t, const uint8_t *, size_t, 60 | uint64_t, uint8_t *, size_t); 61 | 62 | #endif /* !_SHA256_H_ */ 63 | -------------------------------------------------------------------------------- /android/src/main/java/app/edge/reactnative/core/Disklet.java: -------------------------------------------------------------------------------- 1 | package app.edge.reactnative.core; 2 | 3 | import android.util.AtomicFile; 4 | import java.io.File; 5 | import java.io.FileOutputStream; 6 | import java.io.IOException; 7 | import java.nio.charset.StandardCharsets; 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | public class Disklet { 12 | private final File mBase; 13 | 14 | public Disklet(File base) { 15 | this.mBase = base; 16 | } 17 | 18 | public void delete(String path) { 19 | File file = new File(mBase, path); 20 | deepDelete(file); 21 | } 22 | 23 | public byte[] getData(String path) throws IOException { 24 | AtomicFile file = new AtomicFile(new File(mBase, path)); 25 | return file.readFully(); 26 | } 27 | 28 | public String getText(String path) throws IOException { 29 | AtomicFile file = new AtomicFile(new File(mBase, path)); 30 | byte[] data = file.readFully(); 31 | return new String(data, StandardCharsets.UTF_8); 32 | } 33 | 34 | public Map list(String path) { 35 | File file = new File(mBase, path); 36 | try { 37 | HashMap out = new HashMap(); 38 | if (file.exists()) { 39 | if (file.isDirectory()) { 40 | String prefix = "".equals(path) ? path : path + "/"; 41 | File[] files = file.listFiles(); 42 | for (File child : files) { 43 | out.put(prefix + child.getName(), child.isDirectory() ? "folder" : "file"); 44 | } 45 | } else { 46 | out.put(path, "file"); 47 | } 48 | } 49 | return out; 50 | } catch (Throwable e) { 51 | return new HashMap(); 52 | } 53 | } 54 | 55 | public void setData(String path, byte[] data) throws IOException { 56 | File file = new File(mBase, path); 57 | writeFile(file, data); 58 | } 59 | 60 | public void setText(String path, String text) throws IOException { 61 | File file = new File(mBase, path); 62 | byte[] data = text.getBytes(StandardCharsets.UTF_8); 63 | writeFile(file, data); 64 | } 65 | 66 | // helpers ----------------------------------------------------------- 67 | 68 | private void deepDelete(File file) { 69 | if (file.isDirectory()) { 70 | for (File child : file.listFiles()) deepDelete(child); 71 | } 72 | new AtomicFile(file).delete(); 73 | } 74 | 75 | private void writeFile(File file, byte[] data) throws IOException { 76 | File parent = file.getParentFile(); 77 | if (!parent.exists()) parent.mkdirs(); 78 | 79 | AtomicFile atomicFile = new AtomicFile(file); 80 | FileOutputStream stream = atomicFile.startWrite(); 81 | try { 82 | stream.write(data); 83 | atomicFile.finishWrite(stream); 84 | } catch (IOException e) { 85 | atomicFile.failWrite(stream); 86 | throw e; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /android/src/main/java/app/edge/reactnative/core/EdgeCorePackage.java: -------------------------------------------------------------------------------- 1 | package app.edge.reactnative.core; 2 | 3 | import androidx.annotation.NonNull; 4 | import com.facebook.react.ReactPackage; 5 | import com.facebook.react.bridge.NativeModule; 6 | import com.facebook.react.bridge.ReactApplicationContext; 7 | import com.facebook.react.uimanager.ViewManager; 8 | import java.util.Collections; 9 | import java.util.List; 10 | 11 | public class EdgeCorePackage implements ReactPackage { 12 | @NonNull 13 | @Override 14 | public List createNativeModules(@NonNull ReactApplicationContext reactContext) { 15 | return Collections.emptyList(); 16 | } 17 | 18 | @NonNull 19 | @Override 20 | public List createViewManagers(@NonNull ReactApplicationContext reactContext) { 21 | return Collections.singletonList(new EdgeCoreWebViewManager(reactContext)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/src/main/java/app/edge/reactnative/core/EdgeCoreWebViewManager.java: -------------------------------------------------------------------------------- 1 | package app.edge.reactnative.core; 2 | 3 | import android.webkit.WebView; 4 | import androidx.annotation.NonNull; 5 | import com.facebook.react.bridge.ReactApplicationContext; 6 | import com.facebook.react.bridge.ReadableArray; 7 | import com.facebook.react.common.MapBuilder; 8 | import com.facebook.react.uimanager.SimpleViewManager; 9 | import com.facebook.react.uimanager.ThemedReactContext; 10 | import com.facebook.react.uimanager.annotations.ReactProp; 11 | import java.util.HashMap; 12 | import java.util.Map; 13 | 14 | public class EdgeCoreWebViewManager extends SimpleViewManager { 15 | public EdgeCoreWebViewManager(ReactApplicationContext context) {} 16 | 17 | @NonNull 18 | @Override 19 | public EdgeCoreWebView createViewInstance(@NonNull ThemedReactContext themedContext) { 20 | return new EdgeCoreWebView(themedContext); 21 | } 22 | 23 | @Override 24 | public Map getExportedCustomDirectEventTypeConstants() { 25 | final Map constants = new HashMap<>(); 26 | constants.put("onMessage", MapBuilder.of("registrationName", "onMessage")); 27 | constants.put("onScriptError", MapBuilder.of("registrationName", "onScriptError")); 28 | return constants; 29 | } 30 | 31 | @NonNull 32 | @Override 33 | public String getName() { 34 | return "EdgeCoreWebView"; 35 | } 36 | 37 | @Override 38 | public void receiveCommand(@NonNull EdgeCoreWebView view, String command, ReadableArray args) { 39 | if ("runJs".equals(command)) { 40 | view.runJs(args.getString(0)); 41 | } 42 | } 43 | 44 | @ReactProp(name = "allowDebugging") 45 | public void setAllowDebugging(@NonNull EdgeCoreWebView view, boolean allow) { 46 | WebView.setWebContentsDebuggingEnabled(allow); 47 | } 48 | 49 | @ReactProp(name = "source") 50 | public void setSource(@NonNull EdgeCoreWebView view, String source) { 51 | view.setSource(source); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /android/src/main/java/app/edge/reactnative/core/PendingCall.java: -------------------------------------------------------------------------------- 1 | package app.edge.reactnative.core; 2 | 3 | public interface PendingCall { 4 | public void resolve(Object value); 5 | 6 | public void reject(String message); 7 | } 8 | -------------------------------------------------------------------------------- /android/src/main/java/app/edge/reactnative/core/StreamStringReader.java: -------------------------------------------------------------------------------- 1 | package app.edge.reactnative.core; 2 | 3 | import android.util.Base64; 4 | import androidx.annotation.NonNull; 5 | import java.io.ByteArrayOutputStream; 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.nio.ByteBuffer; 9 | import java.nio.charset.CharacterCodingException; 10 | import java.nio.charset.CodingErrorAction; 11 | import java.nio.charset.StandardCharsets; 12 | 13 | /** Consumes an input stream and converts it to text. */ 14 | class StreamStringReader extends ByteArrayOutputStream { 15 | public void read(@NonNull InputStream in, int sizeHint) throws IOException { 16 | int size; 17 | byte[] data = new byte[sizeHint > 0 ? sizeHint : 4096]; 18 | while ((size = in.read(data)) > 0) { 19 | write(data, 0, size); 20 | } 21 | } 22 | 23 | public @NonNull String getUtf8() throws CharacterCodingException { 24 | return StandardCharsets.UTF_8 25 | .newDecoder() 26 | .onMalformedInput(CodingErrorAction.REPORT) 27 | .onUnmappableCharacter(CodingErrorAction.REPORT) 28 | .decode(ByteBuffer.wrap(buf, 0, count)) 29 | .toString(); 30 | } 31 | 32 | public @NonNull String getBase64() { 33 | return Base64.encodeToString(buf, 0, count, Base64.NO_WRAP); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /edge-core-js.podspec: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | package = JSON.parse(File.read(File.join(__dir__, "package.json"))) 4 | 5 | Pod::Spec.new do |s| 6 | s.name = package['name'] 7 | s.version = package['version'] 8 | s.summary = package['description'] 9 | s.homepage = package['homepage'] 10 | s.license = package['license'] 11 | s.authors = package['author'] 12 | 13 | s.platform = :ios, "9.0" 14 | s.requires_arc = true 15 | s.source = { 16 | :git => "https://github.com/EdgeApp/edge-core-js.git", 17 | :tag => "v#{s.version}" 18 | } 19 | s.source_files = 20 | "android/src/main/cpp/scrypt/crypto_scrypt.c", 21 | "android/src/main/cpp/scrypt/crypto_scrypt.h", 22 | "android/src/main/cpp/scrypt/sha256.c", 23 | "android/src/main/cpp/scrypt/sha256.h", 24 | "android/src/main/cpp/scrypt/sysendian.h", 25 | "ios/Disklet.swift", 26 | "ios/edge-core-js-Bridging-Header.h", 27 | "ios/EdgeCoreWebView.swift", 28 | "ios/EdgeCoreWebViewManager.m", 29 | "ios/EdgeCoreWebViewManager.swift", 30 | "ios/EdgeNative.swift", 31 | "ios/PendingCall.swift" 32 | 33 | s.resource_bundles = { 34 | "edge-core-js" => "android/src/main/assets/edge-core-js/edge-core.js" 35 | } 36 | 37 | s.dependency "React-Core" 38 | end 39 | -------------------------------------------------------------------------------- /flow-typed/react-native-libs.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | declare module 'react-native' { 4 | declare module.exports: any 5 | } 6 | -------------------------------------------------------------------------------- /ios/Disklet.swift: -------------------------------------------------------------------------------- 1 | class Disklet { 2 | let baseUrl: URL 3 | 4 | init() { 5 | let paths = NSSearchPathForDirectoriesInDomains( 6 | .documentDirectory, 7 | .userDomainMask, 8 | true 9 | ) 10 | baseUrl = URL.init(fileURLWithPath: paths[0]) 11 | } 12 | 13 | func delete(path: String) throws { 14 | let url = URL.init(fileURLWithPath: path, relativeTo: baseUrl) 15 | do { 16 | try FileManager().removeItem(at: url) 17 | } catch CocoaError.fileNoSuchFile {} 18 | } 19 | 20 | func getData(path: String) throws -> Data { 21 | let url = URL.init(fileURLWithPath: path, relativeTo: baseUrl) 22 | return try Data.init(contentsOf: url) 23 | } 24 | 25 | func getText(path: String) throws -> String { 26 | let url = URL.init(fileURLWithPath: path, relativeTo: baseUrl) 27 | return try String.init(contentsOf: url) 28 | } 29 | 30 | func list(path: String) throws -> [String: String] { 31 | let url = URL.init(fileURLWithPath: path, relativeTo: baseUrl) 32 | let fs = FileManager() 33 | 34 | let isDirectory = try? url.resourceValues( 35 | forKeys: [.isDirectoryKey] 36 | ).isDirectory 37 | if isDirectory == nil { return [:] } 38 | if !isDirectory! { return [path: "file"] } 39 | 40 | let prefix = path == "" ? "" : path + "/" 41 | var out: [String: String] = [:] 42 | let urls = try fs.contentsOfDirectory( 43 | at: url, 44 | includingPropertiesForKeys: [.isDirectoryKey], 45 | options: [.skipsSubdirectoryDescendants] 46 | ) 47 | for item in urls { 48 | if let isDirectory = try? item.resourceValues( 49 | forKeys: [.isDirectoryKey] 50 | ).isDirectory { 51 | out[prefix + item.lastPathComponent] = isDirectory ? "folder" : "file" 52 | } 53 | } 54 | return out 55 | } 56 | 57 | func setData(path: String, data: Data) throws { 58 | let url: URL = URL.init(fileURLWithPath: path, relativeTo: baseUrl) 59 | 60 | try FileManager().createDirectory( 61 | at: url.deletingLastPathComponent(), 62 | withIntermediateDirectories: true 63 | ) 64 | try data.write(to: url, options: [.atomic]) 65 | } 66 | 67 | func setText(path: String, text: String) throws { 68 | let url: URL = URL.init(fileURLWithPath: path, relativeTo: baseUrl) 69 | try FileManager().createDirectory( 70 | at: url.deletingLastPathComponent(), 71 | withIntermediateDirectories: true 72 | ) 73 | try text.write(to: url, atomically: true, encoding: .utf8) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ios/EdgeCoreWebViewManager.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface RCT_EXTERN_MODULE(EdgeCoreWebViewManager, RCTViewManager) 4 | RCT_EXPORT_VIEW_PROPERTY(onMessage, RCTDirectEventBlock) 5 | RCT_EXPORT_VIEW_PROPERTY(onScriptError, RCTDirectEventBlock) 6 | RCT_EXPORT_VIEW_PROPERTY(allowDebugging, BOOL) 7 | RCT_EXPORT_VIEW_PROPERTY(source, NSString) 8 | RCT_EXTERN_METHOD(runJs:(nonnull NSNumber *)reactTag js:(nonnull NSString *)js) 9 | @end 10 | -------------------------------------------------------------------------------- /ios/EdgeCoreWebViewManager.swift: -------------------------------------------------------------------------------- 1 | @objc(EdgeCoreWebViewManager) class EdgeCoreWebViewManager: RCTViewManager { 2 | override static func requiresMainQueueSetup() -> Bool { return false } 3 | override func view() -> UIView! { return EdgeCoreWebView() } 4 | 5 | @objc func runJs(_ reactTag: NSNumber, js: String) { 6 | bridge.uiManager.addUIBlock({ (uiManager, viewRegistry) in 7 | let view = uiManager?.view(forReactTag: reactTag) 8 | if let webView = view as? EdgeCoreWebView { 9 | webView.runJs(js: js) 10 | } 11 | }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/PendingCall.swift: -------------------------------------------------------------------------------- 1 | struct PendingCall { 2 | var resolve: (_ value: Any?) -> Void 3 | var reject: (_ message: String) -> Void 4 | } 5 | -------------------------------------------------------------------------------- /ios/edge-core-js-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | #include "crypto_scrypt.h" 5 | -------------------------------------------------------------------------------- /ios/edge-core-js.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // Placeholder so react-native autolinking can find us. 2 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import flowEntry from 'rollup-plugin-flow-entry' 4 | import mjs from 'rollup-plugin-mjs-entry' 5 | 6 | import packageJson from './package.json' 7 | 8 | const extensions = ['.ts'] 9 | 10 | const babelOpts = { 11 | babelHelpers: 'bundled', 12 | babelrc: false, 13 | extensions, 14 | plugins: ['babel-plugin-transform-fake-error-class'], 15 | presets: ['@babel/preset-typescript', '@babel/preset-react'] 16 | } 17 | 18 | const external = ['crypto', ...Object.keys(packageJson.dependencies)] 19 | 20 | const resolveOpts = { extensions } 21 | 22 | // Produces the Node entry point and standalone type definition files. 23 | export default [ 24 | { 25 | external, 26 | input: './src/index.ts', 27 | output: { file: packageJson.main, format: 'cjs' }, 28 | plugins: [ 29 | resolve(resolveOpts), 30 | babel(babelOpts), 31 | flowEntry({ types: './lib/flow/exports.js' }), 32 | mjs() 33 | ] 34 | }, 35 | { 36 | external, 37 | input: './src/types/types.ts', 38 | output: { file: './types.js', format: 'cjs' }, 39 | plugins: [ 40 | resolve(resolveOpts), 41 | babel(babelOpts), 42 | flowEntry({ types: './lib/flow/types.js' }), 43 | mjs() 44 | ] 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /scripts/make-types.ts: -------------------------------------------------------------------------------- 1 | // Run as `node -r sucrase/register scripts/make-types.ts` 2 | 3 | import { makeNodeDisklet } from 'disklet' 4 | import prettier from 'prettier' 5 | 6 | async function tsToFlow(code: string): Promise { 7 | // First, use Prettier to add semicolons everywhere: 8 | const formatted = await prettier.format(code, { 9 | parser: 'typescript', 10 | semi: true 11 | }) 12 | 13 | const fixed = formatted 14 | // Language differences: 15 | .replace(/\breadonly /g, '+') 16 | .replace(/\bexport declare const\b/g, 'declare export var') 17 | .replace(/\bexport declare\b/g, 'declare export') 18 | .replace(/\binterface (\w+) {/g, 'type $1 = {') 19 | .replace(/\binterface (\w+)<([^>]+)> {/g, 'type $1<$2> = {') 20 | .replace( 21 | /\binterface (\w+) extends (\w+) {/g, 22 | 'type $1 = {\n ...$Exact<$2>;' 23 | ) 24 | .replace(/\bunknown\b/g, 'mixed') 25 | .replace(/\| undefined\b/g, '| void') 26 | .replace(/: undefined\b/g, ': void') 27 | 28 | // Builtin types: 29 | .replace(/\b(\w+): ComponentType/g, '$Rest<$1, { ... }>') 31 | .replace(/\bAsyncIterableIterator\b/g, 'AsyncGenerator') 32 | 33 | return '// @flow\n\n' + fixed 34 | } 35 | 36 | async function main(): Promise { 37 | const disklet = makeNodeDisklet('.') 38 | const listing = await disklet.list('src/types') 39 | const paths = Object.keys(listing).filter(name => listing[name] === 'file') 40 | 41 | // Transpile Flow types to Typescript: 42 | for (const path of paths) { 43 | const source = await disklet.getText(path) 44 | 45 | const flowPath = path 46 | .replace('src/types/', 'lib/flow/') 47 | .replace('.ts', '.js') 48 | const flowSource = await tsToFlow(source) 49 | 50 | await disklet.setText(flowPath, flowSource) 51 | } 52 | } 53 | 54 | main().catch(error => { 55 | console.error(error) 56 | process.exit(1) 57 | }) 58 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | import { makeContext, makeFakeWorld } from './core/core' 2 | import { defaultOnLog } from './core/log/log' 3 | import { makeBrowserIo } from './io/browser/browser-io' 4 | import { 5 | EdgeContext, 6 | EdgeContextOptions, 7 | EdgeFakeUser, 8 | EdgeFakeWorld, 9 | EdgeFakeWorldOptions 10 | } from './types/types' 11 | 12 | export { makeBrowserIo } 13 | export { 14 | addEdgeCorePlugins, 15 | closeEdge, 16 | lockEdgeCorePlugins, 17 | makeFakeIo 18 | } from './core/core' 19 | export * from './types/types' 20 | 21 | export function makeEdgeContext( 22 | opts: EdgeContextOptions 23 | ): Promise { 24 | const { crashReporter, onLog = defaultOnLog } = opts 25 | return makeContext( 26 | { io: makeBrowserIo(), nativeIo: {} }, 27 | { crashReporter, onLog }, 28 | opts 29 | ) 30 | } 31 | 32 | export function makeFakeEdgeWorld( 33 | users: EdgeFakeUser[] = [], 34 | opts: EdgeFakeWorldOptions = {} 35 | ): Promise { 36 | const { crashReporter, onLog = defaultOnLog } = opts 37 | return Promise.resolve( 38 | makeFakeWorld( 39 | { io: makeBrowserIo(), nativeIo: {} }, 40 | { crashReporter, onLog }, 41 | users 42 | ) 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/client-side.ts: -------------------------------------------------------------------------------- 1 | import { close, shareData } from 'yaob' 2 | 3 | import { 4 | EdgePasswordRules, 5 | EdgeStreamTransactionOptions, 6 | EdgeTransaction, 7 | EdgeWalletInfoFull 8 | } from './types/types' 9 | 10 | export interface InternalWalletStream { 11 | next: () => Promise<{ 12 | done: boolean 13 | value: EdgeTransaction[] 14 | }> 15 | } 16 | 17 | export interface InternalWalletMethods { 18 | $internalStreamTransactions: ( 19 | opts: EdgeStreamTransactionOptions 20 | ) => Promise 21 | } 22 | 23 | /** 24 | * Client-side EdgeAccount methods. 25 | */ 26 | export class AccountSync { 27 | readonly allKeys!: EdgeWalletInfoFull[] 28 | 29 | getFirstWalletInfo(type: string): EdgeWalletInfoFull | undefined { 30 | return this.allKeys.find(info => info.type === type) 31 | } 32 | 33 | getWalletInfo(id: string): EdgeWalletInfoFull | undefined { 34 | return this.allKeys.find(info => info.id === id) 35 | } 36 | 37 | listWalletIds(): string[] { 38 | return this.allKeys.map(info => info.id) 39 | } 40 | } 41 | shareData(AccountSync.prototype, 'AccountSync') 42 | 43 | /** 44 | * Verifies that a password meets our suggested rules. 45 | */ 46 | export function checkPasswordRules(password: string): EdgePasswordRules { 47 | const tooShort = password.length < 10 48 | const noNumber = !/[0-9]/.test(password) 49 | const noLowerCase = !/[a-z]/.test(password) 50 | const noUpperCase = !/[A-Z]/.test(password) 51 | 52 | // Quick & dirty password strength estimation: 53 | const charset = 54 | (/[0-9]/.test(password) ? 10 : 0) + 55 | (/[A-Z]/.test(password) ? 26 : 0) + 56 | (/[a-z]/.test(password) ? 26 : 0) + 57 | (/[^0-9A-Za-z]/.test(password) ? 30 : 0) 58 | const secondsToCrack = Math.pow(charset, password.length) / 1e6 59 | 60 | return { 61 | secondsToCrack, 62 | tooShort, 63 | noNumber, 64 | noLowerCase, 65 | noUpperCase, 66 | passed: 67 | password.length >= 16 || 68 | !(tooShort || noNumber || noUpperCase || noLowerCase) 69 | } 70 | } 71 | shareData({ checkPasswordRules }) 72 | 73 | /** 74 | * Normalizes a username, and checks for invalid characters. 75 | * TODO: Support a wider character range via Unicode normalization. 76 | */ 77 | export function fixUsername(username: string): string { 78 | const out = username 79 | .toLowerCase() 80 | .replace(/[ \f\r\n\t\v]+/g, ' ') 81 | .replace(/ $/, '') 82 | .replace(/^ /, '') 83 | 84 | for (let i = 0; i < out.length; ++i) { 85 | const c = out.charCodeAt(i) 86 | if (c < 0x20 || c > 0x7e) { 87 | throw new Error('Bad characters in username') 88 | } 89 | } 90 | return out 91 | } 92 | shareData({ fixUsername }) 93 | 94 | /** 95 | * Synchronously constructs a transaction stream. 96 | * This method creates a secret internal stream, 97 | * which differs slightly from the AsyncIterableIterator protocol 98 | * because of YAOB limitations. 99 | * It then wraps the internal stream object with the correct API. 100 | */ 101 | export function streamTransactions( 102 | this: InternalWalletMethods, 103 | opts: EdgeStreamTransactionOptions 104 | ): AsyncIterableIterator { 105 | let stream: InternalWalletStream | undefined 106 | let streamClosed = false 107 | 108 | const out: AsyncIterableIterator = { 109 | next: async () => { 110 | if (stream == null) stream = await this.$internalStreamTransactions(opts) 111 | if (!streamClosed) { 112 | const out = await stream.next() 113 | if (!out.done) return out 114 | close(stream) 115 | streamClosed = true 116 | } 117 | return { done: true, value: undefined } 118 | }, 119 | 120 | /** 121 | * Closes the iterator early if the client doesn't want all the results. 122 | * This is necessary to prevent memory leaks over the bridge. 123 | */ 124 | return: async () => { 125 | if (stream != null && !streamClosed) { 126 | close(stream) 127 | streamClosed = true 128 | } 129 | return { done: true, value: undefined } 130 | }, 131 | 132 | [Symbol.asyncIterator]: () => out 133 | } 134 | return out 135 | } 136 | shareData({ streamTransactions }, 'CurrencyWalletSync') 137 | -------------------------------------------------------------------------------- /src/core/account/account-cleaners.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asArray, 3 | asBoolean, 4 | asNumber, 5 | asObject, 6 | asOptional, 7 | asString 8 | } from 'cleaners' 9 | 10 | import { asBase16 } from '../../types/server-cleaners' 11 | import { EdgeDenomination, EdgeToken } from '../../types/types' 12 | import { asJsonObject } from '../../util/file-helpers' 13 | import { SwapSettings } from './account-types' 14 | 15 | // --------------------------------------------------------------------- 16 | // building-block types 17 | // --------------------------------------------------------------------- 18 | 19 | const asEdgeDenomination = asObject({ 20 | multiplier: asString, 21 | name: asString, 22 | symbol: asOptional(asString) 23 | }) 24 | 25 | const asEdgeToken = asObject({ 26 | currencyCode: asString, 27 | denominations: asArray(asEdgeDenomination), 28 | displayName: asString, 29 | networkLocation: asOptional(asJsonObject) 30 | }) 31 | 32 | const asSwapSettings = asObject({ 33 | enabled: asOptional(asBoolean, true) 34 | }).withRest 35 | 36 | // --------------------------------------------------------------------- 37 | // file types 38 | // --------------------------------------------------------------------- 39 | 40 | /** 41 | * An Airbitz Bitcoin wallet, which includes the private key & state. 42 | */ 43 | export const asLegacyWalletFile = asObject({ 44 | SortIndex: asOptional(asNumber, 0), 45 | Archived: asOptional(asBoolean, false), 46 | BitcoinSeed: asBase16, 47 | MK: asBase16, 48 | SyncKey: asBase16 49 | }).withRest 50 | 51 | /** 52 | * An Edge wallet state file. The keys are stored in the login server. 53 | */ 54 | export const asWalletStateFile = asObject({ 55 | id: asString, 56 | archived: asOptional(asBoolean), 57 | deleted: asOptional(asBoolean), 58 | hidden: asOptional(asBoolean), 59 | migratedFromWalletId: asOptional(asString), 60 | sortIndex: asOptional(asNumber) 61 | }) 62 | 63 | /** 64 | * Stores settings for currency and swap plugins. 65 | */ 66 | export const asPluginSettingsFile = asObject({ 67 | // Currency plugins: 68 | userSettings: asOptional(asObject(asJsonObject), () => ({})), 69 | 70 | // Swap plugins: 71 | swapSettings: asOptional(asObject(asSwapSettings), () => ({})) 72 | }).withRest 73 | 74 | /** 75 | * The settings file managed by the GUI. 76 | */ 77 | export const asGuiSettingsFile = asObject({ 78 | customTokens: asArray( 79 | asObject({ 80 | contractAddress: asString, 81 | currencyCode: asString, 82 | currencyName: asString, 83 | denomination: asString, 84 | denominations: asArray(asEdgeDenomination), 85 | isVisible: asOptional(asBoolean, true), 86 | multiplier: asString, 87 | walletType: asOptional(asString, 'wallet:ethereum') 88 | }) 89 | ) 90 | }) 91 | 92 | export const asCustomTokensFile = asObject({ 93 | customTokens: asObject(asObject(asEdgeToken)) 94 | }) 95 | -------------------------------------------------------------------------------- /src/core/account/account-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Swap plugin settings. 3 | */ 4 | export interface SwapSettings { 5 | enabled: boolean 6 | } 7 | -------------------------------------------------------------------------------- /src/core/account/data-store-api.ts: -------------------------------------------------------------------------------- 1 | import { asObject, asString } from 'cleaners' 2 | import { justFiles, justFolders } from 'disklet' 3 | import { bridgifyObject } from 'yaob' 4 | 5 | import { EdgeDataStore } from '../../types/types' 6 | import { makeJsonFile } from '../../util/file-helpers' 7 | import { ApiInput } from '../root-pixie' 8 | import { 9 | getStorageWalletDisklet, 10 | hashStorageWalletFilename 11 | } from '../storage/storage-selectors' 12 | 13 | /** 14 | * Each data store folder has a "Name.json" file with this format. 15 | */ 16 | const storeIdFile = makeJsonFile( 17 | asObject({ 18 | name: asString 19 | }) 20 | ) 21 | 22 | /** 23 | * The items saved in a data store have this format. 24 | */ 25 | const storeItemFile = makeJsonFile( 26 | asObject({ 27 | key: asString, 28 | data: asString 29 | }) 30 | ) 31 | 32 | export function makeDataStoreApi( 33 | ai: ApiInput, 34 | accountId: string 35 | ): EdgeDataStore { 36 | const { accountWalletInfo } = ai.props.state.accounts[accountId] 37 | const disklet = getStorageWalletDisklet(ai.props.state, accountWalletInfo.id) 38 | 39 | // Path manipulation: 40 | const hashName = (data: string): string => 41 | hashStorageWalletFilename(ai.props.state, accountWalletInfo.id, data) 42 | const getStorePath = (storeId: string): string => 43 | `Plugins/${hashName(storeId)}` 44 | const getItemPath = (storeId: string, itemId: string): string => 45 | `${getStorePath(storeId)}/${hashName(itemId)}.json` 46 | 47 | const out: EdgeDataStore = { 48 | async deleteItem(storeId: string, itemId: string): Promise { 49 | await disklet.delete(getItemPath(storeId, itemId)) 50 | }, 51 | 52 | async deleteStore(storeId: string): Promise { 53 | await disklet.delete(getStorePath(storeId)) 54 | }, 55 | 56 | async listItemIds(storeId: string): Promise { 57 | const itemIds: string[] = [] 58 | const paths = justFiles(await disklet.list(getStorePath(storeId))) 59 | await Promise.all( 60 | paths.map(async path => { 61 | const clean = await storeItemFile.load(disklet, path) 62 | if (clean != null) itemIds.push(clean.key) 63 | }) 64 | ) 65 | return itemIds 66 | }, 67 | 68 | async listStoreIds(): Promise { 69 | const storeIds: string[] = [] 70 | const paths = justFolders(await disklet.list('Plugins')) 71 | await Promise.all( 72 | paths.map(async path => { 73 | const clean = await storeIdFile.load(disklet, `${path}/Name.json`) 74 | if (clean != null) storeIds.push(clean.name) 75 | }) 76 | ) 77 | return storeIds 78 | }, 79 | 80 | async getItem(storeId: string, itemId: string): Promise { 81 | const clean = await storeItemFile.load( 82 | disklet, 83 | getItemPath(storeId, itemId) 84 | ) 85 | if (clean == null) throw new Error(`No item named "${itemId}"`) 86 | return clean.data 87 | }, 88 | 89 | async setItem( 90 | storeId: string, 91 | itemId: string, 92 | value: string 93 | ): Promise { 94 | // Set up the plugin folder, if needed: 95 | const namePath = `${getStorePath(storeId)}/Name.json` 96 | const clean = await storeIdFile.load(disklet, namePath) 97 | if (clean == null) { 98 | await storeIdFile.save(disklet, namePath, { name: storeId }) 99 | } else if (clean.name !== storeId) { 100 | throw new Error(`Warning: folder name doesn't match for ${storeId}`) 101 | } 102 | 103 | // Set up the actual item: 104 | await storeItemFile.save(disklet, getItemPath(storeId, itemId), { 105 | key: itemId, 106 | data: value 107 | }) 108 | } 109 | } 110 | bridgifyObject(out) 111 | 112 | return out 113 | } 114 | -------------------------------------------------------------------------------- /src/core/context/client-file.ts: -------------------------------------------------------------------------------- 1 | import { asBoolean, asMaybe, asObject } from 'cleaners' 2 | 3 | import { asBase64 } from '../../types/server-cleaners' 4 | import { makeJsonFile } from '../../util/file-helpers' 5 | 6 | export const CLIENT_FILE_NAME = 'client.json' 7 | 8 | export interface ClientInfo { 9 | clientId: Uint8Array 10 | /** 11 | * This is a boolean flag that puts the device into duress mode. 12 | */ 13 | duressEnabled: boolean 14 | } 15 | 16 | export const clientFile = makeJsonFile( 17 | asObject({ 18 | clientId: asBase64, 19 | duressEnabled: asMaybe(asBoolean, false) 20 | }) 21 | ) 22 | -------------------------------------------------------------------------------- /src/core/context/context-pixie.ts: -------------------------------------------------------------------------------- 1 | import { 2 | combinePixies, 3 | filterPixie, 4 | stopUpdates, 5 | TamePixie 6 | } from 'redux-pixies' 7 | import { close, update } from 'yaob' 8 | 9 | import { EdgeContext, EdgeLogSettings, EdgeUserInfo } from '../../types/types' 10 | import { makePeriodicTask } from '../../util/periodic-task' 11 | import { shuffle } from '../../util/shuffle' 12 | import { ApiInput, RootProps } from '../root-pixie' 13 | import { makeContextApi } from './context-api' 14 | import { 15 | asInfoCacheFile, 16 | INFO_CACHE_FILE_NAME, 17 | infoCacheFile 18 | } from './info-cache-file' 19 | 20 | export interface ContextOutput { 21 | api: EdgeContext 22 | } 23 | 24 | export const context: TamePixie = combinePixies({ 25 | api(ai: ApiInput) { 26 | return { 27 | destroy() { 28 | close(ai.props.output.context.api) 29 | }, 30 | update() { 31 | ai.onOutput(makeContextApi(ai)) 32 | return stopUpdates 33 | } 34 | } 35 | }, 36 | 37 | watcher(ai: ApiInput) { 38 | let lastLocalUsers: EdgeUserInfo[] | undefined 39 | let lastPaused: boolean | undefined 40 | let lastLogSettings: EdgeLogSettings | undefined 41 | 42 | return () => { 43 | if ( 44 | lastLocalUsers !== ai.props.state.login.localUsers || 45 | lastPaused !== ai.props.state.paused || 46 | lastLogSettings !== ai.props.state.logSettings 47 | ) { 48 | lastLocalUsers = ai.props.state.login.localUsers 49 | lastPaused = ai.props.state.paused 50 | lastLogSettings = ai.props.state.logSettings 51 | if (ai.props.output.context.api != null) { 52 | update(ai.props.output.context.api) 53 | } 54 | } 55 | } 56 | }, 57 | 58 | infoFetcher: filterPixie( 59 | (input: ApiInput) => { 60 | async function doInfoSync(): Promise { 61 | const { dispatch, io } = input.props 62 | 63 | const [infoServerUri] = shuffle(input.props.state.infoServers) 64 | const response = await fetch(`${infoServerUri}/v1/coreRollup`, { 65 | headers: { accept: 'application/json' } 66 | }) 67 | if (!response.ok) return 68 | const json = await response.json() 69 | 70 | const infoCache = asInfoCacheFile(json) 71 | dispatch({ 72 | type: 'INFO_CACHE_FETCHED', 73 | payload: infoCache 74 | }) 75 | await infoCacheFile.save(io.disklet, INFO_CACHE_FILE_NAME, infoCache) 76 | } 77 | 78 | const infoTask = makePeriodicTask(doInfoSync, 10 * 60 * 1000, { 79 | onError(error) { 80 | input.props.onError(error) 81 | } 82 | }) 83 | 84 | return { 85 | update() { 86 | if (!infoTask.started) infoTask.start() 87 | }, 88 | destroy() { 89 | infoTask.stop() 90 | } 91 | } 92 | }, 93 | props => (props.state.paused ? undefined : props) 94 | ) 95 | }) 96 | -------------------------------------------------------------------------------- /src/core/context/info-cache-file.ts: -------------------------------------------------------------------------------- 1 | import { asArray, asObject, asString } from 'cleaners' 2 | 3 | import { EdgePluginMap, JsonObject } from '../../browser' 4 | import { asJsonObject, makeJsonFile } from '../../util/file-helpers' 5 | 6 | export interface InfoCacheFile { 7 | corePlugins?: EdgePluginMap 8 | syncServers?: string[] 9 | } 10 | 11 | export const INFO_CACHE_FILE_NAME = 'infoCache.json' 12 | 13 | export const asInfoCacheFile = asObject({ 14 | corePlugins: asObject(asJsonObject), 15 | syncServers: asArray(asString) 16 | }) 17 | 18 | export const infoCacheFile = makeJsonFile(asInfoCacheFile) 19 | -------------------------------------------------------------------------------- /src/core/context/internal-api.ts: -------------------------------------------------------------------------------- 1 | import { Disklet } from 'disklet' 2 | import { Bridgeable, bridgifyObject, close, emit, update } from 'yaob' 3 | import { Unsubscribe } from 'yavent' 4 | 5 | import { EdgeLobbyRequest, LoginRequestBody } from '../../types/server-types' 6 | import { EdgeContext } from '../../types/types' 7 | import { 8 | fetchLobbyRequest, 9 | LobbyInstance, 10 | makeLobby, 11 | sendLobbyReply 12 | } from '../login/lobby' 13 | import { loginFetch } from '../login/login-fetch' 14 | import { hashUsername } from '../login/login-selectors' 15 | import { ApiInput } from '../root-pixie' 16 | import { makeRepoPaths, syncRepo, SyncResult } from '../storage/repo' 17 | 18 | /** 19 | * The requesting side of an Edge login lobby. 20 | * The `replies` property will update as replies come in. 21 | */ 22 | class EdgeLobby extends Bridgeable< 23 | { 24 | replies: unknown[] 25 | lobbyId: string 26 | }, 27 | { error: Error } 28 | > { 29 | _lobby: LobbyInstance 30 | _cleanups: Unsubscribe[] 31 | 32 | constructor(lobby: LobbyInstance) { 33 | super() 34 | this._lobby = lobby 35 | 36 | this._cleanups = [ 37 | lobby.close, 38 | lobby.on('reply', reply => update(this, 'replies')), 39 | lobby.on('error', error => emit(this, 'error', error)) 40 | ] 41 | } 42 | 43 | get lobbyId(): string { 44 | return this._lobby.lobbyId 45 | } 46 | 47 | get replies(): unknown[] { 48 | return this._lobby.replies 49 | } 50 | 51 | close(): void { 52 | this._cleanups.forEach(f => f()) 53 | close(this) 54 | } 55 | } 56 | 57 | /** 58 | * A secret internal API which has some goodies for the CLI 59 | * and for unit testing. 60 | */ 61 | export class EdgeInternalStuff extends Bridgeable<{}> { 62 | _ai: ApiInput 63 | 64 | constructor(ai: ApiInput) { 65 | super() 66 | this._ai = ai 67 | } 68 | 69 | authRequest( 70 | method: string, 71 | path: string, 72 | body?: LoginRequestBody 73 | ): Promise { 74 | return loginFetch(this._ai, method, path, body) 75 | } 76 | 77 | hashUsername(username: string): Promise { 78 | return hashUsername(this._ai, username) 79 | } 80 | 81 | async makeLobby( 82 | lobbyRequest: Partial, 83 | period: number = 1000 84 | ): Promise { 85 | const lobby = await makeLobby(this._ai, lobbyRequest, period) 86 | return new EdgeLobby(lobby) 87 | } 88 | 89 | fetchLobbyRequest(lobbyId: string): Promise { 90 | return fetchLobbyRequest(this._ai, lobbyId) 91 | } 92 | 93 | async sendLobbyReply( 94 | lobbyId: string, 95 | lobbyRequest: EdgeLobbyRequest, 96 | replyData: unknown 97 | ): Promise { 98 | await sendLobbyReply(this._ai, lobbyId, lobbyRequest, replyData) 99 | } 100 | 101 | async syncRepo(syncKey: Uint8Array): Promise { 102 | const { io, syncClient } = this._ai.props 103 | const paths = makeRepoPaths(io, { dataKey: new Uint8Array(0), syncKey }) 104 | return await syncRepo(syncClient, paths, { 105 | lastSync: 0, 106 | lastHash: undefined 107 | }) 108 | } 109 | 110 | async getRepoDisklet( 111 | syncKey: Uint8Array, 112 | dataKey: Uint8Array 113 | ): Promise { 114 | const { io } = this._ai.props 115 | const paths = makeRepoPaths(io, { dataKey, syncKey }) 116 | bridgifyObject(paths.disklet) 117 | return paths.disklet 118 | } 119 | } 120 | 121 | /** 122 | * Our public Flow types don't include the internal stuff, 123 | * so this function hacks around Flow to retrieve it. 124 | */ 125 | export function getInternalStuff(context: EdgeContext): EdgeInternalStuff { 126 | const flowHack: any = context 127 | return flowHack.$internalStuff 128 | } 129 | -------------------------------------------------------------------------------- /src/core/core.ts: -------------------------------------------------------------------------------- 1 | export { makeFakeIo } from './fake/fake-io' 2 | export { makeFakeWorld } from './fake/fake-world' 3 | export { 4 | addEdgeCorePlugins, 5 | lockEdgeCorePlugins 6 | } from './plugins/plugins-actions' 7 | export { closeEdge, makeContext } from './root' 8 | -------------------------------------------------------------------------------- /src/core/currency/change-server-connection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | changeProtocol, 3 | SubscribeParams, 4 | SubscribeResult 5 | } from './change-server-protocol' 6 | 7 | interface ChangeServerCallbacks { 8 | handleChange: (address: SubscribeParams) => void 9 | handleConnect: () => void 10 | handleDisconnect: () => void 11 | handleSubLost: (params: SubscribeParams) => void 12 | } 13 | 14 | export interface ChangeServerConnection { 15 | subscribe: (params: SubscribeParams[]) => Promise 16 | unsubscribe: (params: SubscribeParams[]) => Promise 17 | close: () => void 18 | connected: boolean 19 | } 20 | 21 | /** 22 | * Bundles a change-server Websocket and codec pair. 23 | */ 24 | export function connectChangeServer( 25 | url: string, 26 | callbacks: ChangeServerCallbacks 27 | ): ChangeServerConnection { 28 | let ws: WebSocket 29 | function makeWs(): void { 30 | ws = new WebSocket(url) 31 | ws.binaryType = 'arraybuffer' 32 | 33 | ws.addEventListener('message', ev => { 34 | codec.handleMessage(ev.data) 35 | }) 36 | 37 | ws.addEventListener('close', () => { 38 | out.connected = false 39 | codec.handleClose() 40 | callbacks.handleDisconnect() 41 | }) 42 | 43 | ws.addEventListener('error', errEvent => { 44 | console.error('changeServer websocket error:', errEvent) 45 | ws.close() 46 | // Reconnect after 5 seconds: 47 | setTimeout(() => { 48 | makeWs() 49 | }, 5000) 50 | }) 51 | 52 | ws.addEventListener('open', () => { 53 | out.connected = true 54 | callbacks.handleConnect() 55 | }) 56 | } 57 | makeWs() 58 | 59 | const codec = changeProtocol.makeClientCodec({ 60 | // We failed to send a message, so shut down the socket: 61 | handleError(err) { 62 | console.error('changeServer error:', err) 63 | ws.close() 64 | }, 65 | 66 | async handleSend(text) { 67 | ws.send(text) 68 | }, 69 | 70 | localMethods: { 71 | update(params) { 72 | callbacks.handleChange(params) 73 | }, 74 | subLost(params) { 75 | callbacks.handleSubLost(params) 76 | } 77 | } 78 | }) 79 | 80 | const out: ChangeServerConnection = { 81 | async subscribe(params) { 82 | return await codec.remoteMethods.subscribe(params) 83 | }, 84 | 85 | async unsubscribe(params) { 86 | await codec.remoteMethods.unsubscribe(params) 87 | }, 88 | 89 | close() { 90 | ws.close() 91 | }, 92 | 93 | connected: false 94 | } 95 | return out 96 | } 97 | -------------------------------------------------------------------------------- /src/core/currency/change-server-protocol.ts: -------------------------------------------------------------------------------- 1 | import { asArray, asOptional, asString, asTuple, asValue } from 'cleaners' 2 | 3 | import { makeRpcProtocol } from '../../util/json-rpc' 4 | 5 | /** 6 | * A chain and address identifier, like `['bitcoin', '19z88q...']` 7 | */ 8 | export type SubscribeParams = [ 9 | pluginId: string, 10 | address: string, 11 | 12 | /** 13 | * Block height or similar. 14 | * Might be missing the first time we scan an address. 15 | */ 16 | checkpoint?: string 17 | ] 18 | 19 | const asSubscribeParams = asTuple( 20 | asString, // pluginId 21 | asString, // address 22 | asOptional(asString) // checkpoint 23 | ) 24 | 25 | export type SubscribeResult = ReturnType 26 | const asSubscribeResult = asValue( 27 | /** Subscribe failed; not supported */ 28 | -1, 29 | /** Subscribe failed; some thing went wrong */ 30 | 0, 31 | /** Subscribe succeeded, no changes */ 32 | 1, 33 | /** Subscribed succeeded, changes present */ 34 | 2 35 | ) 36 | 37 | export const changeProtocol = makeRpcProtocol({ 38 | serverMethods: { 39 | subscribe: { 40 | asParams: asArray(asSubscribeParams), 41 | asResult: asArray(asSubscribeResult) 42 | }, 43 | 44 | unsubscribe: { 45 | asParams: asArray(asSubscribeParams), 46 | asResult: asValue(undefined) 47 | } 48 | }, 49 | 50 | clientMethods: { 51 | update: { 52 | asParams: asSubscribeParams 53 | }, 54 | subLost: { 55 | asParams: asSubscribeParams 56 | } 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /src/core/currency/currency-reducer.ts: -------------------------------------------------------------------------------- 1 | import { buildReducer, mapReducer } from 'redux-keto' 2 | 3 | import { RootAction } from '../actions' 4 | import { RootState } from '../root-reducer' 5 | import { 6 | currencyWalletReducer, 7 | CurrencyWalletState 8 | } from './wallet/currency-wallet-reducer' 9 | 10 | export interface CurrencyState { 11 | readonly currencyWalletIds: string[] 12 | readonly wallets: { [walletId: string]: CurrencyWalletState } 13 | } 14 | 15 | export const currency = buildReducer({ 16 | currencyWalletIds(state, action, next): string[] { 17 | // Optimize the common case: 18 | if (next.accountIds.length === 1) { 19 | const id = next.accountIds[0] 20 | return next.accounts[id].activeWalletIds 21 | } 22 | 23 | const out: string[] = [] 24 | for (const accountId of next.accountIds) { 25 | out.push(...next.accounts[accountId].activeWalletIds) 26 | } 27 | return out 28 | }, 29 | 30 | wallets: mapReducer( 31 | currencyWalletReducer, 32 | (props: RootState) => props.currency.currencyWalletIds 33 | ) 34 | }) 35 | -------------------------------------------------------------------------------- /src/core/currency/currency-selectors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EdgeCurrencyInfo, 3 | EdgeCurrencyWallet, 4 | EdgeTokenMap 5 | } from '../../types/types' 6 | import { ApiInput, RootProps } from '../root-pixie' 7 | 8 | export function getCurrencyMultiplier( 9 | currencyInfo: EdgeCurrencyInfo, 10 | allTokens: EdgeTokenMap, 11 | currencyCode: string 12 | ): string { 13 | for (const denomination of currencyInfo.denominations) { 14 | if (denomination.name === currencyCode) { 15 | return denomination.multiplier 16 | } 17 | } 18 | 19 | for (const tokenId of Object.keys(allTokens)) { 20 | const token = allTokens[tokenId] 21 | for (const denomination of token.denominations) { 22 | if (denomination.name === currencyCode) { 23 | return denomination.multiplier 24 | } 25 | } 26 | } 27 | 28 | return '1' 29 | } 30 | 31 | export function waitForCurrencyWallet( 32 | ai: ApiInput, 33 | walletId: string 34 | ): Promise { 35 | const out: Promise = ai.waitFor( 36 | (props: RootProps): EdgeCurrencyWallet | undefined => { 37 | // If the wallet id doesn't even exist, bail out: 38 | if (props.state.currency.wallets[walletId] == null) { 39 | throw new Error(`Wallet id ${walletId} does not exist in this account`) 40 | } 41 | 42 | // Return the error if one exists: 43 | const { engineFailure } = props.state.currency.wallets[walletId] 44 | if (engineFailure != null) throw engineFailure 45 | 46 | // Return the API if that exists: 47 | if (props.output.currency.wallets[walletId] != null) { 48 | return props.output.currency.wallets[walletId].walletApi 49 | } 50 | } 51 | ) 52 | return out 53 | } 54 | -------------------------------------------------------------------------------- /src/core/currency/wallet/currency-wallet-export.ts: -------------------------------------------------------------------------------- 1 | import { EdgeTransaction } from '../../../types/types' 2 | import { ApiInput } from '../../root-pixie' 3 | 4 | export function dateFilter( 5 | tx: EdgeTransaction, 6 | afterDate: Date = new Date(0), 7 | beforeDate: Date = new Date() 8 | ): boolean { 9 | return ( 10 | tx.date * 1000 >= afterDate.valueOf() && 11 | tx.date * 1000 < beforeDate.valueOf() 12 | ) 13 | } 14 | 15 | export function searchStringFilter( 16 | ai: ApiInput, 17 | tx: EdgeTransaction, 18 | searchString: string | undefined 19 | ): boolean { 20 | const currencyState = ai.props.state.currency 21 | 22 | if (searchString == null || searchString === '') return true 23 | 24 | // Sanitize search string 25 | let cleanString = searchString.toLowerCase().replace('.', '').replace(',', '') 26 | // Remove leading zeroes 27 | for (let i = 0; i < cleanString.length; i++) { 28 | if (cleanString[i] !== '0') { 29 | cleanString = cleanString.substring(i) 30 | break 31 | } 32 | } 33 | 34 | function checkNullTypeAndIndex(value: string | number): boolean { 35 | if ( 36 | value == null || 37 | (typeof value !== 'string' && typeof value !== 'number') 38 | ) 39 | return false 40 | if ( 41 | !value 42 | .toString() 43 | .toLowerCase() 44 | .replace('.', '') 45 | .replace(',', '') 46 | .includes(cleanString) 47 | ) 48 | return false 49 | return true 50 | } 51 | 52 | if (checkNullTypeAndIndex(tx.nativeAmount)) return true 53 | if (tx.metadata != null) { 54 | const { 55 | category = '', 56 | name = '', 57 | notes = '', 58 | exchangeAmount = {} 59 | } = tx.metadata 60 | const txCurrencyWalletState = 61 | tx.walletId != null ? currencyState.wallets[tx.walletId] : undefined 62 | if ( 63 | checkNullTypeAndIndex(category) || 64 | checkNullTypeAndIndex(name) || 65 | checkNullTypeAndIndex(notes) || 66 | (txCurrencyWalletState != null && 67 | checkNullTypeAndIndex(exchangeAmount[txCurrencyWalletState.fiat])) 68 | ) 69 | return true 70 | } 71 | if (tx.swapData != null) { 72 | const { displayName = '', pluginId = '' } = tx.swapData.plugin 73 | if (checkNullTypeAndIndex(displayName) || checkNullTypeAndIndex(pluginId)) 74 | return true 75 | } 76 | const action = tx.savedAction ?? tx.chainAction 77 | 78 | if (action != null) { 79 | if (action.actionType === 'swap') { 80 | const { pluginId: destPluginId } = action.toAsset 81 | const { pluginId: sourcePluginId } = action.fromAsset 82 | const { displayName, supportEmail } = action.swapInfo 83 | if ( 84 | checkNullTypeAndIndex(sourcePluginId) || 85 | checkNullTypeAndIndex(destPluginId) || 86 | checkNullTypeAndIndex(displayName) || 87 | checkNullTypeAndIndex(supportEmail) 88 | ) 89 | return true 90 | } 91 | } 92 | if (tx.spendTargets != null) { 93 | for (const target of tx.spendTargets) { 94 | const { publicAddress = '', memo = '' } = target 95 | if (checkNullTypeAndIndex(publicAddress) || checkNullTypeAndIndex(memo)) 96 | return true 97 | } 98 | } 99 | if (tx.ourReceiveAddresses.length > 0) { 100 | for (const address of tx.ourReceiveAddresses) { 101 | if (checkNullTypeAndIndex(address)) return true 102 | } 103 | } 104 | if (checkNullTypeAndIndex(tx.txid)) return true 105 | return false 106 | } 107 | -------------------------------------------------------------------------------- /src/core/currency/wallet/enabled-tokens.ts: -------------------------------------------------------------------------------- 1 | import { EdgeCurrencyInfo, EdgeTokenMap } from '../../../types/types' 2 | 3 | function flipTokenMap(tokens: EdgeTokenMap): { 4 | [currencyCode: string]: string 5 | } { 6 | const out: { [currencyCode: string]: string } = {} 7 | for (const tokenId of Object.keys(tokens)) { 8 | const token = tokens[tokenId] 9 | out[token.currencyCode] = tokenId 10 | } 11 | return out 12 | } 13 | 14 | export function currencyCodesToTokenIds( 15 | builtinTokens: EdgeTokenMap = {}, 16 | customTokens: EdgeTokenMap = {}, 17 | currencyInfo: EdgeCurrencyInfo, 18 | currencyCodes: string[] 19 | ): string[] { 20 | const builtinIds = flipTokenMap(builtinTokens) 21 | const customIds = flipTokenMap(customTokens) 22 | 23 | const out: string[] = [] 24 | for (const currencyCode of currencyCodes) { 25 | const tokenId = customIds[currencyCode] ?? builtinIds[currencyCode] 26 | if (tokenId != null) out.push(tokenId) 27 | } 28 | return out 29 | } 30 | 31 | export function tokenIdsToCurrencyCodes( 32 | builtinTokens: EdgeTokenMap = {}, 33 | customTokens: EdgeTokenMap = {}, 34 | currencyInfo: EdgeCurrencyInfo, 35 | tokenIds: string[] 36 | ): string[] { 37 | const out: string[] = [] 38 | for (const tokenId of tokenIds) { 39 | const token = customTokens[tokenId] ?? builtinTokens[tokenId] 40 | if (token != null) out.push(token.currencyCode) 41 | } 42 | return out 43 | } 44 | 45 | /** 46 | * Returns the unique items of an array, 47 | * optionally removing the items in `omit`. 48 | */ 49 | export function uniqueStrings(array: string[], omit: string[] = []): string[] { 50 | const table = new Set(omit) 51 | 52 | const out: string[] = [] 53 | for (const item of array) { 54 | if (table.has(item)) continue 55 | table.add(item) 56 | out.push(item) 57 | } 58 | return out 59 | } 60 | -------------------------------------------------------------------------------- /src/core/currency/wallet/max-spend.ts: -------------------------------------------------------------------------------- 1 | import { add, div, lte, sub } from 'biggystring' 2 | 3 | import { 4 | EdgeCurrencyEngine, 5 | EdgeCurrencyPlugin, 6 | EdgeSpendInfo, 7 | EdgeTokenMap, 8 | EdgeWalletInfo 9 | } from '../../../browser' 10 | import { upgradeCurrencyCode } from '../../../types/type-helpers' 11 | import { upgradeMemos } from './upgrade-memos' 12 | 13 | export const getMaxSpendableInner = async ( 14 | spendInfo: EdgeSpendInfo, 15 | plugin: EdgeCurrencyPlugin, 16 | engine: EdgeCurrencyEngine, 17 | allTokens: EdgeTokenMap, 18 | walletInfo: EdgeWalletInfo 19 | ): Promise => { 20 | spendInfo = upgradeMemos(spendInfo, plugin.currencyInfo) 21 | // Figure out which asset this is: 22 | const upgradedCurrency = upgradeCurrencyCode({ 23 | allTokens, 24 | currencyInfo: plugin.currencyInfo, 25 | tokenId: spendInfo.tokenId 26 | }) 27 | 28 | const unsafeMakeSpend = plugin.currencyInfo.unsafeMakeSpend ?? false 29 | 30 | if (typeof engine.getMaxSpendable === 'function') { 31 | // Only provide wallet info if currency requires it: 32 | const privateKeys = unsafeMakeSpend ? walletInfo.keys : undefined 33 | 34 | return await engine.getMaxSpendable( 35 | { ...spendInfo, ...upgradedCurrency }, 36 | { privateKeys } 37 | ) 38 | } 39 | 40 | const { networkFeeOption, customNetworkFee } = spendInfo 41 | const balance = engine.getBalance(upgradedCurrency) 42 | 43 | // Copy all the spend targets, setting the amounts to 0 44 | // but keeping all other information so we can get accurate fees: 45 | const spendTargets = spendInfo.spendTargets.map(spendTarget => { 46 | return { ...spendTarget, nativeAmount: '0' } 47 | }) 48 | 49 | // The range of possible values includes `min`, but not `max`. 50 | function getMax(min: string, max: string): Promise { 51 | const diff = sub(max, min) 52 | if (lte(diff, '1')) { 53 | return Promise.resolve(min) 54 | } 55 | const mid = add(min, div(diff, '2')) 56 | 57 | // Try the average: 58 | spendTargets[0].nativeAmount = mid 59 | 60 | // Only provide wallet info if currency requires it: 61 | const privateKeys = unsafeMakeSpend ? walletInfo.keys : undefined 62 | 63 | return engine 64 | .makeSpend( 65 | { 66 | ...upgradedCurrency, 67 | spendTargets, 68 | networkFeeOption, 69 | customNetworkFee 70 | }, 71 | { privateKeys } 72 | ) 73 | .then(() => getMax(mid, max)) 74 | .catch(() => getMax(min, mid)) 75 | } 76 | 77 | return await getMax('0', add(balance, '1')) 78 | } 79 | -------------------------------------------------------------------------------- /src/core/currency/wallet/metadata.ts: -------------------------------------------------------------------------------- 1 | import { asNumber, asObject, asOptional, asString, Cleaner } from 'cleaners' 2 | 3 | import { EdgeMetadata, EdgeMetadataChange } from '../../../types/types' 4 | 5 | export const asEdgeMetadata: Cleaner = raw => { 6 | const clean = asDiskMetadata(raw) 7 | const { exchangeAmount = {} } = clean 8 | 9 | // Delete corrupt amounts that exceed the Javascript number range: 10 | for (const fiat of Object.keys(exchangeAmount)) { 11 | if (String(exchangeAmount[fiat]).includes('e')) { 12 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 13 | delete exchangeAmount[fiat] 14 | } 15 | } 16 | 17 | return clean 18 | } 19 | 20 | export function mergeMetadata( 21 | under: EdgeMetadata, 22 | over: EdgeMetadata | EdgeMetadataChange 23 | ): EdgeMetadata { 24 | const out: EdgeMetadata = { exchangeAmount: {} } 25 | const { exchangeAmount = {} } = out 26 | 27 | // Merge the fiat amounts: 28 | const underAmounts = under.exchangeAmount ?? {} 29 | const overAmounts = over.exchangeAmount ?? {} 30 | for (const fiat of Object.keys(underAmounts)) { 31 | if (overAmounts[fiat] !== null) exchangeAmount[fiat] = underAmounts[fiat] 32 | } 33 | for (const fiat of Object.keys(overAmounts)) { 34 | const amount = overAmounts[fiat] 35 | if (amount != null) exchangeAmount[fiat] = amount 36 | } 37 | 38 | // Merge simple fields: 39 | if (over.bizId !== null) out.bizId = over.bizId ?? under.bizId 40 | if (over.category !== null) out.category = over.category ?? under.category 41 | if (over.name !== null) out.name = over.name ?? under.name 42 | if (over.notes !== null) out.notes = over.notes ?? under.notes 43 | 44 | return out 45 | } 46 | 47 | const asDiskMetadata = asObject({ 48 | bizId: asOptional(asNumber), 49 | category: asOptional(asString), 50 | exchangeAmount: asOptional(asObject(asNumber)), 51 | name: asOptional(asString), 52 | notes: asOptional(asString) 53 | }) 54 | -------------------------------------------------------------------------------- /src/core/currency/wallet/upgrade-memos.ts: -------------------------------------------------------------------------------- 1 | import { EdgeCurrencyInfo, EdgeMemo, EdgeSpendInfo } from '../../../types/types' 2 | 3 | /** 4 | * Upgrades the memo fields inside an EdgeSpendTarget, 5 | * so any combination of legacy or modern apps or plugins will work. 6 | */ 7 | export function upgradeMemos( 8 | spendInfo: EdgeSpendInfo, 9 | currencyInfo: EdgeCurrencyInfo 10 | ): EdgeSpendInfo { 11 | const legacyMemos: EdgeMemo[] = [] 12 | 13 | // If this chain supports legacy memos, grab those: 14 | const { memoType } = currencyInfo 15 | if (memoType === 'hex' || memoType === 'number' || memoType === 'text') { 16 | for (const target of spendInfo.spendTargets) { 17 | if (target.memo != null) { 18 | legacyMemos.push({ 19 | type: memoType, 20 | value: target.memo 21 | }) 22 | } else if (target.uniqueIdentifier != null) { 23 | legacyMemos.push({ 24 | type: memoType, 25 | value: target.uniqueIdentifier 26 | }) 27 | } else if (typeof target.otherParams?.uniqueIdentifier === 'string') { 28 | legacyMemos.push({ 29 | type: memoType, 30 | value: target.otherParams.uniqueIdentifier 31 | }) 32 | } 33 | } 34 | } 35 | 36 | // We need to support 0x prefixes for backwards compatibility: 37 | for (const memo of legacyMemos) { 38 | if (memo.type === 'hex') memo.value = memo.value.replace(/^0x/i, '') 39 | } 40 | 41 | // Make a modern, legacy-free spend target: 42 | const out: EdgeSpendInfo = { 43 | ...spendInfo, 44 | 45 | // Delete any legacy memo fields: 46 | spendTargets: spendInfo.spendTargets.map(target => ({ 47 | ...target, 48 | memo: undefined, 49 | uniqueIdentifier: undefined 50 | })), 51 | 52 | // Only use the legacy memos if new ones are missing: 53 | memos: spendInfo.memos ?? legacyMemos 54 | } 55 | 56 | // If we have exactly one memo, copy it to the legacy fields 57 | // to support out-dated currency plugins: 58 | if (out.memos?.length === 1 && out.spendTargets.length >= 1) { 59 | const [memo] = out.memos 60 | if (memo.type === currencyInfo.memoType) { 61 | const [target] = out.spendTargets 62 | target.memo = memo.value 63 | target.uniqueIdentifier = memo.value 64 | target.otherParams = { 65 | ...target.otherParams, 66 | uniqueIdenfitifer: memo.value 67 | } 68 | } 69 | } 70 | 71 | return out 72 | } 73 | -------------------------------------------------------------------------------- /src/core/fake/fake-io.ts: -------------------------------------------------------------------------------- 1 | import { makeMemoryDisklet } from 'disklet' 2 | 3 | import { 4 | EdgeFetchFunction, 5 | EdgeIo, 6 | EdgeRandomFunction 7 | } from '../../types/types' 8 | import { scrypt } from '../../util/crypto/scrypt' 9 | 10 | /** 11 | * Generates deterministic "random" data for unit-testing. 12 | */ 13 | function makeFakeRandom(): EdgeRandomFunction { 14 | let seed = 0 15 | 16 | return (bytes: number) => { 17 | const out = new Uint8Array(bytes) 18 | 19 | for (let i = 0; i < bytes; ++i) { 20 | // Simplest numbers that give a full-period generator with 21 | // a good mix of high & low values within the first few bytes: 22 | seed = (5 * seed + 3) & 0xff 23 | out[i] = seed 24 | } 25 | 26 | return out 27 | } 28 | } 29 | 30 | const fakeFetch: EdgeFetchFunction = () => { 31 | return Promise.reject(new Error('Fake network error')) 32 | } 33 | 34 | /** 35 | * Creates a simulated io context object. 36 | */ 37 | export function makeFakeIo(): EdgeIo { 38 | const out: EdgeIo = { 39 | // Crypto: 40 | random: makeFakeRandom(), 41 | scrypt, 42 | 43 | // Local io: 44 | disklet: makeMemoryDisklet(), 45 | 46 | // Networking: 47 | fetch: fakeFetch, 48 | fetchCors: fakeFetch 49 | } 50 | return out 51 | } 52 | -------------------------------------------------------------------------------- /src/core/log/log.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EdgeCrashReporter, 3 | EdgeLog, 4 | EdgeLogEvent, 5 | EdgeLogMethod, 6 | EdgeLogSettings, 7 | EdgeOnLog 8 | } from '../../types/types' 9 | import { addHiddenProperties } from '../../util/util' 10 | 11 | export interface LogBackend { 12 | crashReporter?: EdgeCrashReporter 13 | onLog: EdgeOnLog 14 | } 15 | 16 | function makeLogMethod( 17 | onLog: EdgeOnLog, 18 | type: EdgeLogEvent['type'], 19 | source: string 20 | ): EdgeLogMethod { 21 | return function log() { 22 | let message = '' 23 | for (let i = 0; i < arguments.length; ++i) { 24 | const arg = arguments[i] 25 | if (i > 0) message += ' ' 26 | if (typeof arg === 'string') message += arg 27 | else if (arg instanceof Error) message += String(arg) 28 | else message += JSON.stringify(arg, null, 2) 29 | } 30 | 31 | onLog({ message, source, time: new Date(), type }) 32 | } 33 | } 34 | 35 | export function defaultOnLog(event: EdgeLogEvent): void { 36 | const prettyDate = event.time 37 | .toISOString() 38 | .replace(/.*(\d\d-\d\d)T(\d\d:\d\d:\d\d).*/, '$1 $2') 39 | console.info(`${prettyDate} ${event.source}: ${event.message}`) 40 | } 41 | 42 | export function filterLogs( 43 | backend: LogBackend, 44 | getSettings: () => EdgeLogSettings 45 | ): LogBackend { 46 | function onLog(event: EdgeLogEvent): void { 47 | const { sources, defaultLogLevel } = getSettings() 48 | 49 | const logLevel = 50 | sources[event.source] != null ? sources[event.source] : defaultLogLevel 51 | 52 | switch (event.type) { 53 | case 'info': 54 | if (logLevel === 'info') backend.onLog(event) 55 | break 56 | case 'warn': 57 | if (logLevel === 'info' || logLevel === 'warn') backend.onLog(event) 58 | break 59 | case 'error': 60 | if (logLevel !== 'silent') backend.onLog(event) 61 | break 62 | } 63 | } 64 | return { ...backend, onLog } 65 | } 66 | 67 | export function makeLog(backend: LogBackend, source: string): EdgeLog { 68 | const { onLog, crashReporter } = backend 69 | 70 | return addHiddenProperties(makeLogMethod(onLog, 'info', source), { 71 | breadcrumb(message: string, metadata: object) { 72 | const time = new Date() 73 | if (crashReporter != null) { 74 | crashReporter.logBreadcrumb({ message, metadata, source, time }) 75 | } else { 76 | message = `${message} ${JSON.stringify(metadata, null, 2)}` 77 | onLog({ message, source, time, type: 'warn' }) 78 | } 79 | }, 80 | crash(error: unknown, metadata: object) { 81 | const time = new Date() 82 | if (crashReporter != null) { 83 | crashReporter.logCrash({ error, metadata, source, time }) 84 | } else { 85 | const message = `${String(error)} ${JSON.stringify(metadata, null, 2)}` 86 | onLog({ message, source, time, type: 'error' }) 87 | } 88 | }, 89 | warn: makeLogMethod(onLog, 'warn', source), 90 | error: makeLogMethod(onLog, 'error', source) 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /src/core/login/airbitz-stashes.ts: -------------------------------------------------------------------------------- 1 | import { asCodec, asObject, asOptional, asString, Cleaner } from 'cleaners' 2 | import { justFolders, navigateDisklet } from 'disklet' 3 | 4 | import { fixUsername } from '../../client-side' 5 | import { asBase32, asEdgeBox, asEdgeSnrp } from '../../types/server-cleaners' 6 | import { EdgeIo } from '../../types/types' 7 | import { base58, utf8 } from '../../util/encoding' 8 | import { makeJsonFile } from '../../util/file-helpers' 9 | import { userIdSnrp } from '../scrypt/scrypt-selectors' 10 | import { LoginStash } from './login-stash' 11 | 12 | /** 13 | * Reads legacy Airbitz login stashes from disk. 14 | */ 15 | export async function loadAirbitzStashes( 16 | io: EdgeIo, 17 | avoidUsernames: Set 18 | ): Promise { 19 | const out: LoginStash[] = [] 20 | 21 | const paths = await io.disklet.list('Accounts').then(justFolders) 22 | for (const path of paths) { 23 | const folder = navigateDisklet(io.disklet, path) 24 | const [ 25 | carePackage, 26 | loginPackage, 27 | otp, 28 | pin2Key, 29 | recovery2Key, 30 | usernameJson 31 | ] = await Promise.all([ 32 | await carePackageFile.load(folder, 'CarePackage.json'), 33 | await loginPackageFile.load(folder, 'LoginPackage.json'), 34 | await otpFile.load(folder, 'OtpKey.json'), 35 | await pin2KeyFile.load(folder, 'Pin2Key.json'), 36 | await recovery2KeyFile.load(folder, 'Recovery2Key.json'), 37 | await usernameFile.load(folder, 'UserName.json') 38 | ]) 39 | 40 | if (usernameJson == null) continue 41 | const username = fixUsername(usernameJson.userName) 42 | if (avoidUsernames.has(username)) continue 43 | const userId = await io.scrypt( 44 | utf8.parse(username), 45 | userIdSnrp.salt_hex, 46 | userIdSnrp.n, 47 | userIdSnrp.r, 48 | userIdSnrp.p, 49 | 32 50 | ) 51 | 52 | // Assemble a modern stash object: 53 | const stash: LoginStash = { 54 | appId: '', 55 | loginId: userId, 56 | pendingVouchers: [], 57 | username 58 | } 59 | if (carePackage != null && loginPackage != null) { 60 | stash.passwordKeySnrp = carePackage.SNRP2 61 | stash.passwordBox = loginPackage.EMK_LP2 62 | stash.syncKeyBox = loginPackage.ESyncKey 63 | stash.passwordAuthBox = loginPackage.ELP1 64 | } 65 | if (otp != null) { 66 | stash.otpKey = otp.TOTP 67 | } 68 | if (pin2Key != null) { 69 | stash.pin2Key = pin2Key.pin2Key 70 | } 71 | if (recovery2Key != null) { 72 | stash.recovery2Key = recovery2Key.recovery2Key 73 | } 74 | 75 | out.push(stash) 76 | } 77 | 78 | return out 79 | } 80 | 81 | /** 82 | * A string of base58-encoded binary data. 83 | */ 84 | const asBase58: Cleaner = asCodec( 85 | raw => base58.parse(asString(raw)), 86 | clean => base58.stringify(clean) 87 | ) 88 | 89 | const carePackageFile = makeJsonFile( 90 | asObject({ 91 | SNRP2: asEdgeSnrp, // passwordKeySnrp 92 | SNRP3: asOptional(asEdgeSnrp), // recoveryKeySnrp 93 | SNRP4: asOptional(asEdgeSnrp), // questionKeySnrp 94 | ERQ: asOptional(asEdgeBox) // questionBox 95 | }) 96 | ) 97 | 98 | const loginPackageFile = makeJsonFile( 99 | asObject({ 100 | EMK_LP2: asEdgeBox, // passwordBox 101 | EMK_LRA3: asOptional(asEdgeBox), // recoveryBox 102 | 103 | ESyncKey: asEdgeBox, // syncKeyBox 104 | ELP1: asEdgeBox // passwordAuthBox 105 | }) 106 | ) 107 | 108 | const otpFile = makeJsonFile(asObject({ TOTP: asBase32 })) 109 | const pin2KeyFile = makeJsonFile(asObject({ pin2Key: asBase58 })) 110 | const recovery2KeyFile = makeJsonFile(asObject({ recovery2Key: asBase58 })) 111 | const usernameFile = makeJsonFile(asObject({ userName: asString })) 112 | -------------------------------------------------------------------------------- /src/core/login/login-delete.ts: -------------------------------------------------------------------------------- 1 | import { ApiInput } from '../root-pixie' 2 | import { makeAuthJson } from './login' 3 | import { loginFetch } from './login-fetch' 4 | import { getStashById } from './login-selectors' 5 | import { LoginTree } from './login-types' 6 | 7 | /** 8 | * Deletes a login from the server. 9 | */ 10 | export async function deleteLogin( 11 | ai: ApiInput, 12 | login: LoginTree 13 | ): Promise { 14 | const { stashTree } = getStashById(ai, login.loginId) 15 | await loginFetch( 16 | ai, 17 | 'POST', 18 | '/v2/login/delete', 19 | makeAuthJson(stashTree, login) 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/core/login/login-messages.ts: -------------------------------------------------------------------------------- 1 | import { base64 } from 'rfc4648' 2 | 3 | import { asMessagesPayload } from '../../types/server-cleaners' 4 | import { EdgeLoginMessage } from '../../types/types' 5 | import { ApiInput } from '../root-pixie' 6 | import { loginFetch } from './login-fetch' 7 | 8 | /** 9 | * Fetches any login-related messages for all the users on this device. 10 | */ 11 | export async function fetchLoginMessages( 12 | ai: ApiInput 13 | ): Promise { 14 | const { stashes } = ai.props.state.login 15 | 16 | const loginMap: { [loginId: string]: string } = {} // loginId -> username 17 | const loginIds: Uint8Array[] = [] 18 | for (const stash of stashes) { 19 | const { loginId, username } = stash 20 | if (username == null) continue 21 | loginMap[base64.stringify(loginId)] = username 22 | loginIds.push(loginId) 23 | } 24 | 25 | const request = { 26 | loginIds 27 | } 28 | const reply = await loginFetch(ai, 'POST', '/v2/messages', request) 29 | const out: EdgeLoginMessage[] = [] 30 | for (const message of asMessagesPayload(reply)) { 31 | const { loginId, ...rest } = message 32 | const id = base64.stringify(loginId) 33 | const username = loginMap[id] 34 | if (username == null) continue 35 | out.push({ ...rest, loginId: id, username }) 36 | } 37 | return out 38 | } 39 | -------------------------------------------------------------------------------- /src/core/login/login-secret.ts: -------------------------------------------------------------------------------- 1 | import { wasChangeSecretPayload } from '../../types/server-cleaners' 2 | import { encrypt } from '../../util/crypto/crypto' 3 | import { ApiInput } from '../root-pixie' 4 | import { LoginKit, LoginTree } from './login-types' 5 | 6 | export function makeSecretKit( 7 | ai: ApiInput, 8 | login: Pick 9 | ): LoginKit { 10 | const { io } = ai.props 11 | const { loginId, loginKey } = login 12 | 13 | const loginAuth = io.random(32) 14 | const loginAuthBox = encrypt(io, loginAuth, loginKey) 15 | 16 | return { 17 | loginId, 18 | server: wasChangeSecretPayload({ 19 | loginAuth, 20 | loginAuthBox 21 | }), 22 | serverPath: '/v2/login/secret', 23 | stash: { 24 | loginAuthBox 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/login/login-selectors.ts: -------------------------------------------------------------------------------- 1 | import { base64 } from 'rfc4648' 2 | 3 | import { verifyData } from '../../util/crypto/verify' 4 | import { ApiInput } from '../root-pixie' 5 | import { scrypt, userIdSnrp } from '../scrypt/scrypt-selectors' 6 | import { searchTree } from './login' 7 | import { LoginStash } from './login-stash' 8 | import { StashLeaf } from './login-types' 9 | 10 | export function getEmptyStash(username?: string): LoginStash { 11 | return { 12 | username, 13 | appId: '', 14 | loginId: new Uint8Array(0), 15 | pendingVouchers: [] 16 | } 17 | } 18 | 19 | /** 20 | * Finds the login stash for the given username. 21 | */ 22 | export function getStashByUsername( 23 | ai: ApiInput, 24 | username: string 25 | ): LoginStash | undefined { 26 | const { stashes } = ai.props.state.login 27 | for (const stash of stashes) { 28 | if (stash.username === username) return stash 29 | } 30 | } 31 | 32 | export function getStashById(ai: ApiInput, loginId: Uint8Array): StashLeaf { 33 | const { stashes } = ai.props.state.login 34 | for (const stashTree of stashes) { 35 | const stash = searchTree(stashTree, stash => 36 | verifyData(stash.loginId, loginId) 37 | ) 38 | if (stash != null) return { stashTree, stash } 39 | } 40 | throw new Error(`Cannot find stash '${base64.stringify(loginId)}'`) 41 | } 42 | 43 | export function getChildStash( 44 | stashTree: LoginStash, 45 | loginId: Uint8Array 46 | ): LoginStash { 47 | const stash = searchTree(stashTree, stash => 48 | verifyData(stash.loginId, loginId) 49 | ) 50 | if (stash != null) return stash 51 | throw new Error(`Cannot find child stash '${base64.stringify(loginId)}'`) 52 | } 53 | 54 | // Hashed username cache: 55 | const userIdCache: { [username: string]: Promise } = {} 56 | 57 | /** 58 | * Hashes a username into a userId. 59 | */ 60 | export function hashUsername( 61 | ai: ApiInput, 62 | username: string 63 | ): Promise { 64 | if (userIdCache[username] == null) { 65 | userIdCache[username] = scrypt(ai, username, userIdSnrp) 66 | } 67 | return userIdCache[username] 68 | } 69 | -------------------------------------------------------------------------------- /src/core/login/login-types.ts: -------------------------------------------------------------------------------- 1 | import { asObject, asString, uncleaner } from 'cleaners' 2 | 3 | import { 4 | EdgePendingVoucher, 5 | EdgeWalletInfo, 6 | EdgeWalletInfoFull 7 | } from '../../types/types' 8 | import { asJsonObject } from '../../util/file-helpers' 9 | import { LoginStash } from './login-stash' 10 | 11 | /** 12 | * A key that decrypts a login stash. 13 | */ 14 | export interface SessionKey { 15 | /** The login that this key belongs to. This may be a child login. */ 16 | loginId: Uint8Array 17 | 18 | /** The decryption key. */ 19 | loginKey: Uint8Array 20 | } 21 | 22 | /** 23 | * The login data decrypted into memory. 24 | * @deprecated Use `LoginStash` instead and decrypt it at the point of use. 25 | * This is an ongoing refactor to remove this type. 26 | */ 27 | export interface LoginTree { 28 | isRoot: boolean 29 | 30 | // Identity: 31 | appId: string 32 | created?: Date 33 | lastLogin: Date 34 | loginId: Uint8Array 35 | loginKey: Uint8Array 36 | 37 | // 2-factor: 38 | otpKey?: Uint8Array 39 | otpResetDate?: Date 40 | otpTimeout?: number 41 | pendingVouchers: EdgePendingVoucher[] 42 | 43 | // Login methods: 44 | loginAuth?: Uint8Array 45 | passwordAuth?: Uint8Array 46 | pin?: string 47 | pin2Key?: Uint8Array 48 | recovery2Key?: Uint8Array 49 | 50 | // Username: 51 | userId?: Uint8Array 52 | username?: string 53 | 54 | // Resources: 55 | children: LoginTree[] 56 | } 57 | 58 | export type LoginType = 59 | | 'edgeLogin' 60 | | 'keyLogin' 61 | | 'newAccount' 62 | | 'passwordLogin' 63 | | 'pinLogin' 64 | | 'recoveryLogin' 65 | 66 | export interface LoginKit { 67 | /** The change will affect the node with this ID. */ 68 | loginId: Uint8Array 69 | 70 | /** 71 | * The login-server payload that achieves the change. 72 | * Not all routes take a payload, such as the DELETE routes. 73 | */ 74 | server: object | undefined 75 | 76 | /** 77 | * The login-server HTTP method that makes the change. 78 | * Defaults to "POST" if not present. 79 | */ 80 | serverMethod?: string 81 | serverPath: string 82 | 83 | /** 84 | * A diff to apply to the stash tree, starting at the `loginId` node. 85 | * TODO: Update the login server to return a diff on every endpoint, 86 | * so we can get rid of this. 87 | */ 88 | stash: Partial 89 | } 90 | 91 | /** 92 | * A stash for a specific child account, 93 | * along with its containing tree. 94 | */ 95 | export interface StashLeaf { 96 | stash: LoginStash 97 | stashTree: LoginStash 98 | } 99 | 100 | export interface WalletInfoFullMap { 101 | [walletId: string]: EdgeWalletInfoFull 102 | } 103 | 104 | export const asEdgeWalletInfo = asObject({ 105 | id: asString, 106 | keys: asJsonObject, 107 | type: asString 108 | }) 109 | 110 | export const wasEdgeWalletInfo = uncleaner(asEdgeWalletInfo) 111 | -------------------------------------------------------------------------------- /src/core/login/login-username.ts: -------------------------------------------------------------------------------- 1 | import { ChangeUsernameOptions } from '../../browser' 2 | import { wasChangeUsernamePayload } from '../../types/server-cleaners' 3 | import { encrypt } from '../../util/crypto/crypto' 4 | import { utf8 } from '../../util/encoding' 5 | import { ApiInput } from '../root-pixie' 6 | import { applyKits } from './login' 7 | import { hashUsername } from './login-selectors' 8 | import { LoginKit, LoginTree } from './login-types' 9 | import { makePasswordKit } from './password' 10 | import { makeChangePin2IdKit } from './pin2' 11 | import { makeChangeRecovery2IdKit } from './recovery2' 12 | 13 | export async function changeUsername( 14 | ai: ApiInput, 15 | accountId: string, 16 | opts: ChangeUsernameOptions 17 | ): Promise { 18 | const { loginTree } = ai.props.state.accounts[accountId] 19 | 20 | function makeKits(login: LoginTree): Array> { 21 | const out = [makeChangeUsernameKit(ai, login, opts)] 22 | for (const child of login.children) { 23 | out.push(...makeKits(child)) 24 | } 25 | return out 26 | } 27 | 28 | const kits = await Promise.all(makeKits(loginTree)) 29 | await applyKits(ai, loginTree, kits) 30 | } 31 | 32 | /** 33 | * Figures out which changes are needed to change a username, 34 | * and combines the necessary kits. 35 | */ 36 | export async function makeChangeUsernameKit( 37 | ai: ApiInput, 38 | login: LoginTree, 39 | opts: ChangeUsernameOptions 40 | ): Promise { 41 | const { password, username } = opts 42 | const { isRoot, loginId, passwordAuth } = login 43 | 44 | // Validate our input: 45 | if (passwordAuth != null && password == null) { 46 | throw new Error('A password is required to change the username') 47 | } 48 | 49 | const pin2Kit = makeChangePin2IdKit(login, username) 50 | const recovery2Kit = makeChangeRecovery2IdKit(login, username) 51 | let passwordKit: LoginKit | undefined 52 | let usernameKit: LoginKit | undefined 53 | 54 | if (password != null) { 55 | passwordKit = await makePasswordKit(ai, login, username, password) 56 | } 57 | if (isRoot) { 58 | usernameKit = await makeUsernameKit(ai, login, username) 59 | } 60 | 61 | // Stop if we have no changes: 62 | if ( 63 | passwordKit == null && 64 | pin2Kit == null && 65 | recovery2Kit == null && 66 | usernameKit == null 67 | ) { 68 | return 69 | } 70 | 71 | return { 72 | loginId, 73 | server: { 74 | ...passwordKit?.server, 75 | ...pin2Kit?.server, 76 | ...recovery2Kit?.server, 77 | ...usernameKit?.server 78 | }, 79 | serverPath: '/v2/login/username', 80 | stash: { 81 | ...passwordKit?.stash, 82 | ...pin2Kit?.stash, 83 | ...recovery2Kit?.stash, 84 | ...usernameKit?.stash 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Creates the values needed to set up a username. 91 | * This is only useful for root logins. 92 | */ 93 | export async function makeUsernameKit( 94 | ai: ApiInput, 95 | login: LoginTree, 96 | username: string 97 | ): Promise { 98 | const { io } = ai.props 99 | const { loginId, loginKey } = login 100 | 101 | const userId = await hashUsername(ai, username) 102 | 103 | return { 104 | loginId, 105 | server: wasChangeUsernamePayload({ 106 | userId, 107 | userTextBox: encrypt(io, utf8.parse(username), loginKey) 108 | }), 109 | serverPath: '', 110 | stash: { userId, username } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/core/login/storage-keys.ts: -------------------------------------------------------------------------------- 1 | import { asObject, uncleaner } from 'cleaners' 2 | 3 | import { asBase64 } from '../../browser' 4 | import { ApiInput } from '../root-pixie' 5 | 6 | export const asEdgeStorageKeys = asObject({ 7 | dataKey: asBase64, 8 | syncKey: asBase64 9 | }) 10 | export const wasEdgeStorageKeys = uncleaner(asEdgeStorageKeys) 11 | export type EdgeStorageKeys = ReturnType 12 | 13 | /** 14 | * Makes keys for accessing an encrypted Git repo. 15 | */ 16 | export function createStorageKeys(ai: ApiInput): EdgeStorageKeys { 17 | const { io } = ai.props 18 | return { 19 | dataKey: io.random(32), 20 | syncKey: io.random(20) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/login/vouchers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asLoginPayload, 3 | wasChangeVouchersPayload 4 | } from '../../types/server-cleaners' 5 | import { ChangeVouchersPayload } from '../../types/server-types' 6 | import { ApiInput } from '../root-pixie' 7 | import { applyLoginPayload, makeAuthJson } from './login' 8 | import { loginFetch } from './login-fetch' 9 | import { getStashById } from './login-selectors' 10 | import { saveStash } from './login-stash' 11 | import { LoginTree } from './login-types' 12 | 13 | /** 14 | * Approves or rejects vouchers on the server. 15 | */ 16 | export async function changeVoucherStatus( 17 | ai: ApiInput, 18 | login: LoginTree, 19 | vouchers: ChangeVouchersPayload 20 | ): Promise { 21 | const { stashTree } = getStashById(ai, login.loginId) 22 | const reply = await loginFetch(ai, 'POST', '/v2/login/vouchers', { 23 | ...makeAuthJson(stashTree, login), 24 | data: wasChangeVouchersPayload(vouchers) 25 | }) 26 | const newStashTree = applyLoginPayload( 27 | stashTree, 28 | login.loginKey, 29 | asLoginPayload(reply) 30 | ) 31 | return await saveStash(ai, newStashTree) 32 | } 33 | -------------------------------------------------------------------------------- /src/core/plugins/plugins-actions.ts: -------------------------------------------------------------------------------- 1 | import { navigateDisklet } from 'disklet' 2 | import { Dispatch } from 'redux' 3 | 4 | import { 5 | EdgeCorePlugin, 6 | EdgeCorePluginOptions, 7 | EdgeCorePlugins, 8 | EdgeCorePluginsInit, 9 | EdgeIo, 10 | EdgeNativeIo, 11 | EdgePluginMap 12 | } from '../../types/types' 13 | import { RootAction } from '../actions' 14 | import { InfoCacheFile } from '../context/info-cache-file' 15 | import { LogBackend, makeLog } from '../log/log' 16 | 17 | export interface PluginIos { 18 | io: EdgeIo 19 | nativeIo: EdgeNativeIo 20 | } 21 | 22 | type PluginsAddedWatcher = (plugins: EdgeCorePlugins) => void 23 | type PluginsLockedWatcher = () => void 24 | 25 | const allPlugins: EdgeCorePlugins = {} 26 | let allPluginsLocked: boolean = false 27 | const onPluginsAdded: PluginsAddedWatcher[] = [] 28 | const onPluginsLocked: PluginsLockedWatcher[] = [] 29 | 30 | /** 31 | * Adds plugins to the core. 32 | */ 33 | export function addEdgeCorePlugins(plugins: EdgeCorePlugins): void { 34 | if (allPluginsLocked) { 35 | throw new Error('The Edge core plugin list has already been locked') 36 | } 37 | 38 | // Save the new plugins: 39 | for (const pluginId of Object.keys(plugins)) { 40 | allPlugins[pluginId] = plugins[pluginId] 41 | } 42 | 43 | // Update already-booted contexts: 44 | for (const f of onPluginsAdded) f(plugins) 45 | } 46 | 47 | /** 48 | * Finalizes the core plugin list, so no further plugins are expected. 49 | */ 50 | export function lockEdgeCorePlugins(): void { 51 | allPluginsLocked = true 52 | for (const f of onPluginsLocked) f() 53 | } 54 | 55 | /** 56 | * Subscribes a context object to the core plugin list. 57 | */ 58 | export function watchPlugins( 59 | ios: PluginIos, 60 | infoCache: InfoCacheFile, 61 | logBackend: LogBackend, 62 | pluginsInit: EdgeCorePluginsInit, 63 | dispatch: Dispatch 64 | ): () => void { 65 | const { io, nativeIo } = ios 66 | const legacyIo = { ...io, console } 67 | 68 | function pluginsAdded(plugins: EdgeCorePlugins): void { 69 | const out: EdgePluginMap = {} 70 | 71 | for (const pluginId of Object.keys(plugins)) { 72 | const plugin = plugins[pluginId] 73 | const log = makeLog(logBackend, pluginId) 74 | const initOptions = pluginsInit[pluginId] 75 | if (initOptions === false || initOptions == null) continue 76 | 77 | // Figure out what kind of object this is: 78 | try { 79 | if (typeof plugin === 'function') { 80 | const opts: EdgeCorePluginOptions = { 81 | infoPayload: infoCache.corePlugins?.[pluginId] ?? {}, 82 | initOptions: typeof initOptions === 'object' ? initOptions : {}, 83 | io: legacyIo, 84 | log, 85 | nativeIo, 86 | pluginDisklet: navigateDisklet(io.disklet, 'plugins/' + pluginId) 87 | } 88 | out[pluginId] = plugin(opts) 89 | } else if (typeof plugin === 'object' && plugin != null) { 90 | out[pluginId] = plugin 91 | } else { 92 | throw new TypeError( 93 | `Plugins must be functions or objects, got ${typeof plugin}` 94 | ) 95 | } 96 | } catch (error: unknown) { 97 | // Show the error but keep going: 98 | log.error(error) 99 | } 100 | } 101 | 102 | dispatch({ type: 'CORE_PLUGINS_ADDED', payload: out }) 103 | } 104 | 105 | function pluginsLocked(): void { 106 | dispatch({ type: 'CORE_PLUGINS_LOCKED', payload: pluginsInit }) 107 | } 108 | 109 | // Add any plugins currently available: 110 | pluginsAdded(allPlugins) 111 | if (allPluginsLocked) pluginsLocked() 112 | 113 | // Save the callbacks: 114 | onPluginsAdded.push(pluginsAdded) 115 | onPluginsLocked.push(pluginsLocked) 116 | 117 | return () => { 118 | onPluginsAdded.filter(f => f !== pluginsAdded) 119 | onPluginsLocked.filter(f => f !== pluginsLocked) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/core/plugins/plugins-reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EdgeCorePluginsInit, 3 | EdgeCurrencyPlugin, 4 | EdgeCurrencyTools, 5 | EdgePluginMap, 6 | EdgeSwapPlugin 7 | } from '../../types/types' 8 | import { RootAction } from '../actions' 9 | 10 | export interface PluginsState { 11 | readonly init: EdgeCorePluginsInit 12 | readonly locked: boolean 13 | 14 | readonly currency: EdgePluginMap 15 | readonly swap: EdgePluginMap 16 | 17 | readonly currencyTools: EdgePluginMap> 18 | } 19 | 20 | const initialState: PluginsState = { 21 | init: {}, 22 | locked: false, 23 | currency: {}, 24 | swap: {}, 25 | currencyTools: {} 26 | } 27 | 28 | export const plugins = ( 29 | state: PluginsState = initialState, 30 | action: RootAction 31 | ): PluginsState => { 32 | switch (action.type) { 33 | case 'CORE_PLUGINS_ADDED': { 34 | const out = { 35 | ...state, 36 | currency: { ...state.currency }, 37 | swap: { ...state.swap } 38 | } 39 | for (const pluginId of Object.keys(action.payload)) { 40 | const plugin = action.payload[pluginId] 41 | 42 | // Don't stop loading the bundle if plugin(s) fail to load. Some plugins may rely on advanced features 43 | // that aren't locally available so we can skip loading those but should continue and load what we can. 44 | if (plugin == null) { 45 | out.init = { ...out.init, [pluginId]: false } 46 | continue 47 | } 48 | 49 | if ('currencyInfo' in plugin) { 50 | // Update the currencyInfo display names, if necessary 51 | if (plugin.currencyInfo.chainDisplayName == null) { 52 | plugin.currencyInfo.chainDisplayName = 53 | plugin.currencyInfo.displayName 54 | } 55 | if (plugin.currencyInfo.assetDisplayName == null) { 56 | plugin.currencyInfo.assetDisplayName = 57 | plugin.currencyInfo.displayName 58 | } 59 | 60 | out.currency[pluginId] = plugin 61 | } 62 | if ('swapInfo' in plugin) out.swap[pluginId] = plugin 63 | } 64 | return out 65 | } 66 | case 'CORE_PLUGINS_LOCKED': 67 | return { ...state, locked: true } 68 | case 'CURRENCY_TOOLS_LOADED': { 69 | const currencyTools = { ...state.currencyTools } 70 | currencyTools[action.payload.pluginId] = action.payload.tools 71 | return { ...state, currencyTools } 72 | } 73 | case 'INIT': 74 | return { ...state, init: action.payload.pluginsInit } 75 | } 76 | return state 77 | } 78 | -------------------------------------------------------------------------------- /src/core/plugins/plugins-selectors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EdgeCurrencyPlugin, 3 | EdgeCurrencyTools, 4 | EdgePluginMap 5 | } from '../../types/types' 6 | import { ApiInput, RootProps } from '../root-pixie' 7 | 8 | /** 9 | * Finds the currency plugin that can handle a particular wallet type, 10 | * or throws an error if there is none. 11 | */ 12 | export function findCurrencyPluginId( 13 | plugins: EdgePluginMap, 14 | walletType: string 15 | ): string { 16 | const pluginId = maybeFindCurrencyPluginId(plugins, walletType) 17 | if (pluginId == null) { 18 | throw new Error( 19 | `Cannot find a currency plugin for wallet type ${walletType}` 20 | ) 21 | } 22 | return pluginId 23 | } 24 | 25 | /** 26 | * Finds the currency plugin that can handle a particular wallet type, 27 | * or `undefined` if there is none. 28 | */ 29 | export function maybeFindCurrencyPluginId( 30 | plugins: EdgePluginMap, 31 | walletType: string 32 | ): string | undefined { 33 | return Object.keys(plugins).find( 34 | pluginId => plugins[pluginId].currencyInfo.walletType === walletType 35 | ) 36 | } 37 | 38 | /** 39 | * Finds the currency tools for a particular wallet type, 40 | * loading them if needed. 41 | */ 42 | export function getCurrencyTools( 43 | ai: ApiInput, 44 | pluginId: string 45 | ): Promise { 46 | const { dispatch, state } = ai.props 47 | 48 | // Already loaded / loading: 49 | const tools = state.plugins.currencyTools[pluginId] 50 | if (tools != null) return tools 51 | 52 | // Never touched, so start the load: 53 | const plugin = state.plugins.currency[pluginId] 54 | const promise = plugin.makeCurrencyTools() 55 | dispatch({ 56 | type: 'CURRENCY_TOOLS_LOADED', 57 | payload: { pluginId, tools: promise } 58 | }) 59 | return promise 60 | } 61 | 62 | /** 63 | * Waits for the plugins to load, 64 | * then validates that all plugins are present. 65 | */ 66 | export async function waitForPlugins(ai: ApiInput): Promise { 67 | await ai.waitFor((props: RootProps): true | undefined => { 68 | const { init, locked } = props.state.plugins 69 | if (!locked) return 70 | 71 | const { currency, swap } = props.state.plugins 72 | const missingPlugins: string[] = [] 73 | for (const pluginId of Object.keys(init)) { 74 | const shouldLoad = init[pluginId] !== false && init[pluginId] != null 75 | if (shouldLoad && currency[pluginId] == null && swap[pluginId] == null) { 76 | missingPlugins.push(pluginId) 77 | } 78 | } 79 | if (missingPlugins.length > 0) { 80 | throw new Error( 81 | 'The following plugins are missing or failed to load: ' + 82 | missingPlugins.join(', ') 83 | ) 84 | } 85 | 86 | return true 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /src/core/root-pixie.ts: -------------------------------------------------------------------------------- 1 | import { SyncClient } from 'edge-sync-client' 2 | import { combinePixies, PixieInput, ReduxProps, TamePixie } from 'redux-pixies' 3 | 4 | import { EdgeIo, EdgeLog } from '../types/types' 5 | import { AccountOutput, accounts } from './account/account-pixie' 6 | import { Dispatch } from './actions' 7 | import { context, ContextOutput } from './context/context-pixie' 8 | import { currency, CurrencyOutput } from './currency/currency-pixie' 9 | import { LogBackend } from './log/log' 10 | import { RootState } from './root-reducer' 11 | import { scrypt, ScryptOutput } from './scrypt/scrypt-pixie' 12 | 13 | // The top-level pixie output structure: 14 | export interface RootOutput { 15 | readonly accounts: { [accountId: string]: AccountOutput } 16 | readonly context: ContextOutput 17 | readonly currency: CurrencyOutput 18 | readonly scrypt: ScryptOutput 19 | } 20 | 21 | // Props passed to the root pixie: 22 | export interface RootProps extends ReduxProps { 23 | readonly close: () => void 24 | readonly io: EdgeIo 25 | readonly log: EdgeLog 26 | readonly logBackend: LogBackend 27 | readonly onError: (error: unknown) => void 28 | readonly output: RootOutput 29 | readonly syncClient: SyncClient 30 | } 31 | 32 | export type ApiInput = PixieInput 33 | 34 | /** 35 | * Downstream pixies take props that extend from `RootProps`, 36 | * so this casts those back down if necessary. 37 | */ 38 | export const toApiInput = (input: PixieInput): ApiInput => input 39 | 40 | export const rootPixie: TamePixie = combinePixies({ 41 | accounts, 42 | context, 43 | currency, 44 | scrypt 45 | }) 46 | -------------------------------------------------------------------------------- /src/core/scrypt/scrypt-selectors.ts: -------------------------------------------------------------------------------- 1 | import { EdgeSnrp } from '../../types/server-types' 2 | import { utf8 } from '../../util/encoding' 3 | import { ApiInput } from '../root-pixie' 4 | 5 | /** 6 | * Computes an SNRP value. 7 | */ 8 | export function makeSnrp( 9 | ai: ApiInput, 10 | targetMs: number = 2000 11 | ): Promise { 12 | return ai.props.output.scrypt.makeSnrp(targetMs) 13 | } 14 | 15 | /** 16 | * Performs an scrypt derivation. 17 | */ 18 | export async function scrypt( 19 | ai: ApiInput, 20 | data: Uint8Array | string, 21 | snrp: EdgeSnrp 22 | ): Promise { 23 | if (typeof data === 'string') data = utf8.parse(data) 24 | 25 | const value = await ai.props.output.scrypt.timeScrypt(data, snrp) 26 | return value.hash 27 | } 28 | 29 | export const userIdSnrp: EdgeSnrp = { 30 | salt_hex: Uint8Array.from([ 31 | 0xb5, 0x86, 0x5f, 0xfb, 0x9f, 0xa7, 0xb3, 0xbf, 0xe4, 0xb2, 0x38, 0x4d, 32 | 0x47, 0xce, 0x83, 0x1e, 0xe2, 0x2a, 0x4a, 0x9d, 0x5c, 0x34, 0xc7, 0xef, 33 | 0x7d, 0x21, 0x46, 0x7c, 0xc7, 0x58, 0xf8, 0x1b 34 | ]), 35 | n: 16384, 36 | r: 1, 37 | p: 1 38 | } 39 | -------------------------------------------------------------------------------- /src/core/storage/encrypt-disklet.ts: -------------------------------------------------------------------------------- 1 | import { Disklet, DiskletListing } from 'disklet' 2 | import { bridgifyObject } from 'yaob' 3 | 4 | import { asEdgeBox, wasEdgeBox } from '../../types/server-cleaners' 5 | import { EdgeIo } from '../../types/types' 6 | import { decrypt, decryptText, encrypt } from '../../util/crypto/crypto' 7 | import { utf8 } from '../../util/encoding' 8 | 9 | export function encryptDisklet( 10 | io: EdgeIo, 11 | dataKey: Uint8Array, 12 | disklet: Disklet 13 | ): Disklet { 14 | const out = { 15 | delete(path: string): Promise { 16 | return disklet.delete(path) 17 | }, 18 | 19 | async getData(path: string): Promise { 20 | const text = await disklet.getText(path) 21 | const box = asEdgeBox(JSON.parse(text)) 22 | return decrypt(box, dataKey) 23 | }, 24 | 25 | async getText(path: string): Promise { 26 | const text = await disklet.getText(path) 27 | const box = asEdgeBox(JSON.parse(text)) 28 | return decryptText(box, dataKey) 29 | }, 30 | 31 | list(path?: string): Promise { 32 | return disklet.list(path) 33 | }, 34 | 35 | setData(path: string, data: ArrayLike): Promise { 36 | return disklet.setText( 37 | path, 38 | JSON.stringify(wasEdgeBox(encrypt(io, Uint8Array.from(data), dataKey))) 39 | ) 40 | }, 41 | 42 | setText(path: string, text: string): Promise { 43 | return this.setData(path, utf8.parse(text)) 44 | } 45 | } 46 | bridgifyObject(out) 47 | return out 48 | } 49 | -------------------------------------------------------------------------------- /src/core/storage/storage-actions.ts: -------------------------------------------------------------------------------- 1 | import { bridgifyObject } from 'yaob' 2 | 3 | import { EdgeWalletInfo } from '../../types/types' 4 | import { asEdgeStorageKeys } from '../login/storage-keys' 5 | import { ApiInput } from '../root-pixie' 6 | import { 7 | loadRepoStatus, 8 | makeLocalDisklet, 9 | makeRepoPaths, 10 | syncRepo 11 | } from './repo' 12 | import { StorageWalletStatus } from './storage-reducer' 13 | 14 | export const SYNC_INTERVAL = 30 * 1000 15 | 16 | export async function addStorageWallet( 17 | ai: ApiInput, 18 | walletInfo: EdgeWalletInfo 19 | ): Promise { 20 | const { dispatch, io, onError } = ai.props 21 | 22 | const storageKeys = asEdgeStorageKeys(walletInfo.keys) 23 | const paths = makeRepoPaths(io, storageKeys) 24 | const localDisklet = makeLocalDisklet(io, walletInfo.id) 25 | bridgifyObject(localDisklet) 26 | 27 | const status: StorageWalletStatus = await loadRepoStatus(paths) 28 | dispatch({ 29 | type: 'STORAGE_WALLET_ADDED', 30 | payload: { 31 | id: walletInfo.id, 32 | initialState: { 33 | localDisklet, 34 | paths, 35 | status, 36 | lastChanges: [] 37 | } 38 | } 39 | }) 40 | 41 | // If we have already done a sync, let this one run in the background: 42 | const syncPromise = syncStorageWallet(ai, walletInfo.id) 43 | if (status.lastSync > 0) { 44 | syncPromise.catch(error => { 45 | const { syncKey } = walletInfo.keys 46 | const { lastHash } = status 47 | ai.props.log.error( 48 | `Could not sync ${String(syncKey)} with last hash ${String( 49 | lastHash 50 | )}: ${String(error)}` 51 | ) 52 | onError(error) 53 | }) 54 | } else await syncPromise 55 | } 56 | 57 | export function syncStorageWallet( 58 | ai: ApiInput, 59 | walletId: string 60 | ): Promise { 61 | const { dispatch, syncClient, state } = ai.props 62 | const { paths, status } = state.storageWallets[walletId] 63 | 64 | return syncRepo(syncClient, paths, { ...status }).then( 65 | ({ changes, status }) => { 66 | dispatch({ 67 | type: 'STORAGE_WALLET_SYNCED', 68 | payload: { id: walletId, changes: Object.keys(changes), status } 69 | }) 70 | return Object.keys(changes) 71 | } 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /src/core/storage/storage-api.ts: -------------------------------------------------------------------------------- 1 | import { Disklet } from 'disklet' 2 | 3 | import { EdgeWalletInfo } from '../../types/types' 4 | import { ApiInput } from '../root-pixie' 5 | import { syncStorageWallet } from './storage-actions' 6 | import { 7 | getStorageWalletDisklet, 8 | getStorageWalletLocalDisklet 9 | } from './storage-selectors' 10 | 11 | export interface EdgeStorageWallet { 12 | readonly id: string 13 | readonly keys: object 14 | readonly type: string 15 | readonly disklet: Disklet 16 | readonly localDisklet: Disklet 17 | sync: () => Promise 18 | } 19 | 20 | export function makeStorageWalletApi( 21 | ai: ApiInput, 22 | walletInfo: EdgeWalletInfo 23 | ): EdgeStorageWallet { 24 | const { id, type, keys } = walletInfo 25 | 26 | return { 27 | // Broken-out key info: 28 | id, 29 | type, 30 | keys, 31 | 32 | // Folders: 33 | get disklet(): Disklet { 34 | return getStorageWalletDisklet(ai.props.state, id) 35 | }, 36 | 37 | get localDisklet(): Disklet { 38 | return getStorageWalletLocalDisklet(ai.props.state, id) 39 | }, 40 | 41 | async sync(): Promise { 42 | await syncStorageWallet(ai, id) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/core/storage/storage-reducer.ts: -------------------------------------------------------------------------------- 1 | import { Disklet } from 'disklet' 2 | import { combineReducers } from 'redux' 3 | 4 | import { RootAction } from '../actions' 5 | 6 | export interface StorageWalletPaths { 7 | dataKey: Uint8Array 8 | syncKey: Uint8Array 9 | 10 | baseDisklet: Disklet 11 | changesDisklet: Disklet 12 | dataDisklet: Disklet 13 | disklet: Disklet 14 | } 15 | 16 | export interface StorageWalletStatus { 17 | lastHash: string | undefined 18 | lastSync: number 19 | } 20 | 21 | export interface StorageWalletState { 22 | lastChanges: string[] 23 | localDisklet: Disklet 24 | paths: StorageWalletPaths 25 | status: StorageWalletStatus 26 | } 27 | 28 | export interface StorageWalletsState { 29 | [id: string]: StorageWalletState 30 | } 31 | 32 | /** 33 | * Individual repo reducer. 34 | */ 35 | const storageWalletReducer = combineReducers({ 36 | lastChanges(state = [], action): string[] { 37 | if (action.type === 'STORAGE_WALLET_SYNCED') { 38 | const { changes } = action.payload 39 | return changes.length > 0 ? changes : state 40 | } 41 | return state 42 | }, 43 | 44 | localDisklet(state: any = null): Disklet { 45 | return state 46 | }, 47 | 48 | paths(state: any = null): StorageWalletPaths { 49 | return state 50 | }, 51 | 52 | status( 53 | state = { lastSync: 0, lastHash: undefined }, 54 | action 55 | ): StorageWalletStatus { 56 | return action.type === 'STORAGE_WALLET_SYNCED' 57 | ? action.payload.status 58 | : state 59 | } 60 | }) 61 | 62 | /** 63 | * Repo list reducer. 64 | */ 65 | export const storageWallets = function storageWalletsReducer( 66 | state: StorageWalletsState = {}, 67 | action: RootAction 68 | ): StorageWalletsState { 69 | switch (action.type) { 70 | case 'STORAGE_WALLET_ADDED': { 71 | const { id, initialState } = action.payload 72 | const out: StorageWalletsState = { ...state } 73 | out[id] = storageWalletReducer(initialState, { type: 'UPDATE_NEXT' }) 74 | return out 75 | } 76 | 77 | case 'STORAGE_WALLET_SYNCED': { 78 | const { id } = action.payload 79 | if (state[id] != null) { 80 | const out: StorageWalletsState = { ...state } 81 | out[id] = storageWalletReducer(state[id], action) 82 | return out 83 | } 84 | return state 85 | } 86 | } 87 | return state 88 | } 89 | -------------------------------------------------------------------------------- /src/core/storage/storage-selectors.ts: -------------------------------------------------------------------------------- 1 | import { Disklet } from 'disklet' 2 | 3 | import { EdgeIo } from '../../types/types' 4 | import { hmacSha256 } from '../../util/crypto/hashes' 5 | import { base58, utf8 } from '../../util/encoding' 6 | import { RootState } from '../root-reducer' 7 | import { encryptDisklet } from './encrypt-disklet' 8 | 9 | export function getStorageWalletLastChanges( 10 | state: RootState, 11 | walletId: string 12 | ): string[] { 13 | return state.storageWallets[walletId].lastChanges 14 | } 15 | 16 | export function getStorageWalletDisklet( 17 | state: RootState, 18 | walletId: string 19 | ): Disklet { 20 | return state.storageWallets[walletId].paths.disklet 21 | } 22 | 23 | export function getStorageWalletLocalDisklet( 24 | state: RootState, 25 | walletId: string 26 | ): Disklet { 27 | return state.storageWallets[walletId].localDisklet 28 | } 29 | 30 | export function makeStorageWalletLocalEncryptedDisklet( 31 | state: RootState, 32 | walletId: string, 33 | io: EdgeIo 34 | ): Disklet { 35 | return encryptDisklet( 36 | io, 37 | state.storageWallets[walletId].paths.dataKey, 38 | state.storageWallets[walletId].localDisklet 39 | ) 40 | } 41 | 42 | export function hashStorageWalletFilename( 43 | state: RootState, 44 | walletId: string, 45 | data: string 46 | ): string { 47 | const dataKey = state.storageWallets[walletId].paths.dataKey 48 | return base58.stringify(hmacSha256(utf8.parse(data), dataKey)) 49 | } 50 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import { StoreEnhancer } from 'redux' 2 | import { Bridge } from 'yaob' 3 | 4 | import { NativeBridge } from './io/react-native/native-bridge' 5 | import { EdgeCorePlugins } from './types/types' 6 | 7 | interface EnhancerOptions { 8 | name?: string 9 | } 10 | 11 | declare global { 12 | interface Window { 13 | __REDUX_DEVTOOLS_EXTENSION__?: (config?: EnhancerOptions) => StoreEnhancer 14 | 15 | /** 16 | * Plugins call this to register themselves with the core. 17 | * We call `lockEdgeCorePlugins` ourselves on React Native. 18 | */ 19 | addEdgeCorePlugins?: (plugins: EdgeCorePlugins) => void 20 | 21 | /** 22 | * Native code calls this bridge to pass back results from IO methods. 23 | */ 24 | nativeBridge: NativeBridge 25 | 26 | /** 27 | * Native code calls this bridge to pass in messages from React Native. 28 | */ 29 | reactBridge: Bridge 30 | 31 | /** 32 | * Our Java code injects this into the Android WebView, 33 | * allowing JavaScript to talk to native code. 34 | */ 35 | edgeCore: { 36 | /** 37 | * Sends a message to the React Native component. 38 | */ 39 | postMessage: (message: unknown) => void 40 | 41 | /** 42 | * Calls a native IO method. 43 | */ 44 | call: (id: number, name: string, args: string) => void 45 | } 46 | 47 | /** 48 | * Our Swift code installs this message handler into the iOS WebView, 49 | * allowing JavaScript to talk to native code. 50 | */ 51 | webkit: { 52 | messageHandlers: { 53 | edgeCore: { 54 | postMessage: (args: [number, string, unknown[]]) => void 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { makeLocalBridge } from 'yaob' 2 | 3 | import { makeContext, makeFakeWorld } from './core/core' 4 | import { defaultOnLog } from './core/log/log' 5 | import { hideProperties } from './io/hidden-properties' 6 | import { makeNodeIo } from './io/node/node-io' 7 | import { 8 | EdgeContext, 9 | EdgeContextOptions, 10 | EdgeFakeUser, 11 | EdgeFakeWorld, 12 | EdgeFakeWorldOptions 13 | } from './types/types' 14 | 15 | export { makeNodeIo } 16 | export { 17 | addEdgeCorePlugins, 18 | closeEdge, 19 | lockEdgeCorePlugins, 20 | makeFakeIo 21 | } from './core/core' 22 | export * from './types/types' 23 | 24 | export function makeEdgeContext( 25 | opts: EdgeContextOptions 26 | ): Promise { 27 | const { crashReporter, onLog = defaultOnLog, path = './edge' } = opts 28 | return makeContext( 29 | { io: makeNodeIo(path), nativeIo: {} }, 30 | { crashReporter, onLog }, 31 | opts 32 | ) 33 | } 34 | 35 | export function makeFakeEdgeWorld( 36 | users: EdgeFakeUser[] = [], 37 | opts: EdgeFakeWorldOptions = {} 38 | ): Promise { 39 | const { crashReporter, onLog = defaultOnLog } = opts 40 | return Promise.resolve( 41 | makeLocalBridge( 42 | makeFakeWorld( 43 | { io: makeNodeIo('.'), nativeIo: {} }, 44 | { crashReporter, onLog }, 45 | users 46 | ), 47 | { 48 | cloneMessage: message => JSON.parse(JSON.stringify(message)), 49 | hideProperties 50 | } 51 | ) 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/io/browser/browser-io.ts: -------------------------------------------------------------------------------- 1 | import { makeLocalStorageDisklet } from 'disklet' 2 | 3 | import { EdgeFetchOptions, EdgeFetchResponse, EdgeIo } from '../../types/types' 4 | import { scrypt } from '../../util/crypto/scrypt' 5 | import { fetchCorsProxy } from './fetch-cors-proxy' 6 | 7 | // Only try CORS proxy/bridge techniques up to 5 times 8 | const MAX_CORS_FAILURE_COUNT = 5 9 | 10 | // A map of domains that failed CORS and succeeded via the CORS proxy server 11 | const hostnameProxyWhitelist = new Set() 12 | 13 | // A map of domains that failed all CORS techniques and should not re-attempt CORS techniques 14 | const hostnameCorsProxyBlacklist = new Map() 15 | 16 | /** 17 | * Extracts the io functions we need from the browser. 18 | */ 19 | export function makeBrowserIo(): EdgeIo { 20 | if (typeof window === 'undefined') { 21 | throw new Error('No `window` object') 22 | } 23 | if (window.crypto == null || window.crypto.getRandomValues == null) { 24 | throw new Error('No secure random number generator in this browser') 25 | } 26 | 27 | const io: EdgeIo = { 28 | // Crypto: 29 | random: size => { 30 | const out = new Uint8Array(size) 31 | window.crypto.getRandomValues(out) 32 | return out 33 | }, 34 | scrypt, 35 | 36 | // Local io: 37 | disklet: makeLocalStorageDisklet(window.localStorage, { 38 | prefix: 'airbitz' 39 | }), 40 | 41 | // Networking: 42 | async fetch( 43 | uri: string, 44 | opts?: EdgeFetchOptions 45 | ): Promise { 46 | const { corsBypass = 'auto' } = opts ?? {} 47 | 48 | if (corsBypass === 'always') { 49 | return await fetchCorsProxy(uri, opts) 50 | } 51 | if (corsBypass === 'never') { 52 | return await window.fetch(uri, opts) 53 | } 54 | 55 | const { hostname } = new URL(uri) 56 | const corsFailureCount = hostnameCorsProxyBlacklist.get(hostname) ?? 0 57 | 58 | let doFetch = true 59 | const doFetchCors = true 60 | 61 | if ( 62 | corsFailureCount < MAX_CORS_FAILURE_COUNT && 63 | hostnameProxyWhitelist.has(hostname) 64 | ) { 65 | // Proactively use fetchCorsProxy for any hostnames added to whitelist: 66 | doFetch = false 67 | } 68 | 69 | let errorToThrow 70 | if (doFetch) { 71 | try { 72 | // Attempt regular fetch: 73 | return await window.fetch(uri, opts) 74 | } catch (error: unknown) { 75 | // If we exhaust attempts to use CORS-safe fetch, then throw the error: 76 | if (corsFailureCount >= MAX_CORS_FAILURE_COUNT) { 77 | throw error 78 | } 79 | errorToThrow = error 80 | } 81 | } 82 | 83 | if (doFetchCors) { 84 | try { 85 | const response = await fetchCorsProxy(uri, opts) 86 | hostnameProxyWhitelist.add(hostname) 87 | return response 88 | } catch (error: unknown) { 89 | if (errorToThrow == null) errorToThrow = error 90 | } 91 | } 92 | 93 | // We failed all CORS techniques, so track attempts 94 | hostnameCorsProxyBlacklist.set(hostname, corsFailureCount + 1) 95 | 96 | // Throw the error from the first fetch instead of the one from 97 | // proxy server. 98 | throw errorToThrow 99 | }, 100 | 101 | async fetchCors( 102 | uri: string, 103 | opts?: EdgeFetchOptions 104 | ): Promise { 105 | return await io.fetch(uri, opts) 106 | } 107 | } 108 | 109 | return io 110 | } 111 | -------------------------------------------------------------------------------- /src/io/browser/fetch-cors-proxy.ts: -------------------------------------------------------------------------------- 1 | import { EdgeFetchOptions } from '../../types/types' 2 | import { asyncWaterfall } from '../../util/asyncWaterfall' 3 | import { shuffle } from '../../util/shuffle' 4 | 5 | // Hard-coded CORS proxy server 6 | const PROXY_SERVER_URLS = [ 7 | 'https://cors1.edge.app', 8 | 'https://cors2.edge.app', 9 | 'https://cors3.edge.app', 10 | 'https://cors4.edge.app' 11 | ] 12 | 13 | export const fetchCorsProxy = async ( 14 | uri: string, 15 | opts?: EdgeFetchOptions 16 | ): Promise => { 17 | const shuffledUrls = shuffle([...PROXY_SERVER_URLS]) 18 | const tasks = shuffledUrls.map( 19 | proxyServerUrl => async () => 20 | await window.fetch(proxyServerUrl, { 21 | ...opts, 22 | headers: { 23 | ...opts?.headers, 24 | 'x-proxy-url': uri 25 | } 26 | }) 27 | ) 28 | return await asyncWaterfall(tasks) 29 | } 30 | -------------------------------------------------------------------------------- /src/io/hidden-properties.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Properties we want YAOB to hide from `console.log` or `JSON.stringify`. 3 | */ 4 | export const hideProperties: string[] = [ 5 | 'allKeys', 6 | 'displayPrivateSeed', 7 | 'displayPublicSeed', 8 | 'keys', 9 | 'otpKey', 10 | 'loginKey', 11 | 'publicWalletInfo', 12 | 'recoveryKey' 13 | ] 14 | -------------------------------------------------------------------------------- /src/io/node/node-io.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { makeNodeDisklet } from 'disklet' 3 | import fetch from 'node-fetch' 4 | 5 | import { EdgeIo } from '../../types/types' 6 | import { scrypt } from '../../util/crypto/scrypt' 7 | 8 | /** 9 | * Creates the io resources needed to run the Edge core on node.js. 10 | * 11 | * @param {string} path Location where data should be written to disk. 12 | */ 13 | export function makeNodeIo(path: string): EdgeIo { 14 | return { 15 | // Crypto: 16 | random(bytes: number) { 17 | return Uint8Array.from(crypto.randomBytes(bytes)) 18 | }, 19 | scrypt, 20 | 21 | // Local io: 22 | disklet: makeNodeDisklet(path), 23 | 24 | // Networking: 25 | fetch, 26 | fetchCors: fetch 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/io/react-native/native-bridge.ts: -------------------------------------------------------------------------------- 1 | interface NativeMethods { 2 | diskletDelete: (path: string) => Promise 3 | diskletGetData: (path: string) => Promise // base64 4 | diskletGetText: (path: string) => Promise 5 | diskletList: (path: string) => Promise<{ [path: string]: 'file' | 'folder' }> 6 | diskletSetData: (path: string, data64: string) => Promise 7 | diskletSetText: (path: string, text: string) => Promise 8 | 9 | fetch: ( 10 | url: string, 11 | method: string, 12 | headers: { [name: string]: string }, 13 | body?: string, 14 | bodyIsBase64?: boolean 15 | ) => Promise<{ 16 | status: number 17 | headers: { [name: string]: string } 18 | body: string 19 | bodyIsBase64: boolean 20 | }> 21 | 22 | randomBytes: (size: number) => Promise // base64 23 | 24 | scrypt: ( 25 | data64: string, 26 | salt64: string, 27 | n: number, 28 | r: number, 29 | p: number, 30 | dklen: number 31 | ) => Promise // base64 32 | } 33 | 34 | export interface NativeBridge { 35 | call: ( 36 | name: Name, 37 | ...args: Parameters 38 | ) => ReturnType 39 | 40 | // The native code uses this method to pass return values. 41 | resolve: (id: number, value: unknown) => void 42 | 43 | // The native code uses this method if a call fails. 44 | reject: (id: number, message: string) => void 45 | } 46 | 47 | export function makeNativeBridge( 48 | doCall: (id: number, name: string, args: unknown[]) => void 49 | ): NativeBridge { 50 | const list = makePendingList() 51 | return { 52 | call(name, ...args) { 53 | const promise = new Promise((resolve, reject) => { 54 | doCall(list.add({ resolve, reject }), name, args) 55 | }) 56 | // TypeScript can't check our Java / Swift return values: 57 | return promise as any 58 | }, 59 | resolve(id, value) { 60 | list.grab(id).resolve(value) 61 | }, 62 | reject(id, message) { 63 | list.grab(id).reject(new Error(message)) 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * A pending call into native code. 70 | */ 71 | interface PendingCall { 72 | resolve: (value: unknown) => void 73 | reject: (error: Error) => void 74 | } 75 | 76 | /** 77 | * Maintains a list of pending native calls. 78 | */ 79 | interface PendingList { 80 | add: (call: PendingCall) => number 81 | grab: (id: number) => PendingCall 82 | } 83 | 84 | function makePendingList(): PendingList { 85 | const dummyCall: PendingCall = { resolve() {}, reject() {} } 86 | let lastId: number = 0 87 | 88 | if (typeof Map !== 'undefined') { 89 | // Better map-based version: 90 | const map = new Map() 91 | return { 92 | add(call) { 93 | const id = ++lastId 94 | map.set(id, call) 95 | return id 96 | }, 97 | grab(id) { 98 | const call = map.get(id) 99 | if (call == null) return dummyCall 100 | map.delete(id) 101 | return call 102 | } 103 | } 104 | } 105 | 106 | // Slower object-based version: 107 | const map: { [id: string]: PendingCall } = {} 108 | return { 109 | add(call) { 110 | const id = ++lastId 111 | map[String(id)] = call 112 | return id 113 | }, 114 | grab(id) { 115 | const call = map[String(id)] 116 | if (call == null) return dummyCall 117 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 118 | delete map[String(id)] 119 | return call 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/io/react-native/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object.assign 3 | */ 4 | function assign(out: any): any { 5 | if (out == null) { 6 | throw new TypeError('Cannot convert undefined or null to object') 7 | } 8 | out = Object(out) 9 | 10 | for (let i = 1; i < arguments.length; ++i) { 11 | const from = arguments[i] 12 | if (from == null) continue 13 | for (const key of Object.keys(from)) out[key] = from[key] 14 | } 15 | return out 16 | } 17 | 18 | /** 19 | * Array.fill 20 | */ 21 | function fill(this: any[], value: any, start?: number, end?: number): any[] { 22 | const length = this.length 23 | function clamp(endpoint: number): number { 24 | return endpoint < 0 25 | ? Math.max(length + endpoint, 0) 26 | : Math.min(endpoint, length) 27 | } 28 | const first = start != null ? clamp(start) : 0 29 | const last = end != null ? clamp(end) : length 30 | 31 | for (let i = first; i < last; ++i) { 32 | this[i] = value 33 | } 34 | return this 35 | } 36 | 37 | /** 38 | * Array.find 39 | */ 40 | function find( 41 | this: any[], 42 | test: (value: any, i: number, array: any[]) => boolean, 43 | testThis?: any 44 | ): any { 45 | for (let i = 0; i < this.length; ++i) { 46 | const value = this[i] 47 | if (test.call(testThis, value, i, this)) { 48 | return value 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Array.includes 55 | */ 56 | function includes(this: any[], target: any): boolean { 57 | return Array.prototype.indexOf.call(this, target) >= 0 58 | } 59 | 60 | /** 61 | * Adds a non-enumerable method to an object. 62 | */ 63 | function safeAdd(object: any, name: string, value: any): void { 64 | if (typeof object[name] !== 'function') { 65 | Object.defineProperty(object, name, { 66 | configurable: true, 67 | writable: true, 68 | value 69 | }) 70 | } 71 | } 72 | 73 | // Perform the polyfill: 74 | safeAdd(Object, 'assign', assign) 75 | safeAdd(Array.prototype, 'fill', fill) 76 | safeAdd(Array.prototype, 'find', find) 77 | safeAdd(Array.prototype, 'includes', includes) 78 | safeAdd(Uint8Array.prototype, 'fill', Array.prototype.fill) 79 | -------------------------------------------------------------------------------- /src/io/react-native/react-native-types.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { LogBackend } from '../../core/log/log' 4 | import { 5 | EdgeContext, 6 | EdgeContextOptions, 7 | EdgeFakeUser, 8 | EdgeFakeWorld, 9 | EdgeNativeIo 10 | } from '../../types/types' 11 | 12 | export interface WorkerApi { 13 | makeEdgeContext: ( 14 | nativeIo: EdgeNativeIo, 15 | logBackend: LogBackend, 16 | pluginUris: string[], 17 | opts: EdgeContextOptions 18 | ) => Promise 19 | 20 | makeFakeEdgeWorld: ( 21 | nativeIo: EdgeNativeIo, 22 | logBackend: LogBackend, 23 | pluginUris: string[], 24 | users?: EdgeFakeUser[] 25 | ) => Promise 26 | } 27 | 28 | export interface EdgeCoreMessageEvent { 29 | nativeEvent: { message: string } 30 | } 31 | 32 | export interface EdgeCoreScriptError { 33 | nativeEvent: { source: string } 34 | } 35 | 36 | export interface EdgeCoreWebViewProps { 37 | allowDebugging?: boolean 38 | source: string | null 39 | style?: any 40 | onMessage?: (event: EdgeCoreMessageEvent) => void 41 | onScriptError?: (event: EdgeCoreScriptError) => void 42 | } 43 | export type EdgeCoreWebView = React.ComponentClass 44 | export type EdgeCoreWebViewRef = React.Component 45 | 46 | // Throttle YAOB updates 47 | export const YAOB_THROTTLE_MS = 50 48 | -------------------------------------------------------------------------------- /src/io/react-native/react-native-webview.tsx: -------------------------------------------------------------------------------- 1 | import '../../client-side' 2 | 3 | import * as React from 'react' 4 | import { requireNativeComponent } from 'react-native' 5 | 6 | import { EdgeCoreWebView, WorkerApi } from './react-native-types' 7 | import { makeYaobCallbacks, YaobCallbacks } from './yaob-callbacks' 8 | 9 | interface Props { 10 | allowDebugging?: boolean 11 | debug?: boolean 12 | onError: (error: unknown) => void 13 | onLoad: (root: WorkerApi) => Promise 14 | } 15 | 16 | /** 17 | * Launches the Edge core worker in a WebView and returns its API. 18 | */ 19 | export class EdgeCoreBridge extends React.Component { 20 | callbacks: YaobCallbacks 21 | 22 | constructor(props: Props) { 23 | super(props) 24 | const { onError, onLoad } = props 25 | 26 | // Set up the YAOB bridge: 27 | this.callbacks = makeYaobCallbacks((root: WorkerApi) => { 28 | onLoad(root).catch(onError) 29 | }) 30 | } 31 | 32 | render(): JSX.Element { 33 | const { allowDebugging = false, debug = false, onError } = this.props 34 | 35 | return ( 36 | { 43 | if (onError != null) { 44 | onError(new Error(`Cannot load "${event.nativeEvent.source}"`)) 45 | } 46 | }} 47 | /> 48 | ) 49 | } 50 | } 51 | 52 | const NativeWebView: EdgeCoreWebView = requireNativeComponent('EdgeCoreWebView') 53 | -------------------------------------------------------------------------------- /src/io/react-native/yaob-callbacks.ts: -------------------------------------------------------------------------------- 1 | import '../../client-side' 2 | 3 | import { findNodeHandle, UIManager } from 'react-native' 4 | import { Bridge, onMethod } from 'yaob' 5 | 6 | import { hideProperties } from '../hidden-properties' 7 | import { 8 | EdgeCoreMessageEvent, 9 | EdgeCoreWebViewRef, 10 | YAOB_THROTTLE_MS 11 | } from './react-native-types' 12 | 13 | export interface YaobCallbacks { 14 | handleMessage: (event: EdgeCoreMessageEvent) => void 15 | setRef: (element: EdgeCoreWebViewRef | null) => void 16 | } 17 | 18 | /** 19 | * Sets up a YAOB bridge for use with a React Native WebView. 20 | * The returned callbacks should be passed to the `onMessage` and `ref` 21 | * properties of the WebView. Handles WebView reloads and related 22 | * race conditions. 23 | * @param {*} onRoot Called when the inner HTML sends a root object. 24 | * May be called multiple times if the inner HTML reloads. 25 | * @param {*} debug Provide a message prefix to enable debugging. 26 | */ 27 | export function makeYaobCallbacks( 28 | onRoot: (root: Root) => void, 29 | debug?: string 30 | ): YaobCallbacks { 31 | let bridge: Bridge | undefined 32 | let gatedRoot: Root | undefined 33 | let webview: EdgeCoreWebViewRef | null = null 34 | 35 | // Gate the root object on the WebView being ready: 36 | function tryReleasingRoot(): void { 37 | if (gatedRoot != null && webview != null) { 38 | onRoot(gatedRoot) 39 | gatedRoot = undefined 40 | } 41 | } 42 | 43 | // Feed incoming messages into the YAOB bridge (if any): 44 | function handleMessage(event: EdgeCoreMessageEvent): void { 45 | const message = JSON.parse(event.nativeEvent.message) 46 | if (debug != null) console.info(`${debug} →`, message) 47 | 48 | // This is a terrible hack. We are using our inside knowledge 49 | // of YAOB's message format to determine when the client has restarted. 50 | if ( 51 | bridge != null && 52 | message.events != null && 53 | message.events.find((event: any) => event.localId === 0) != null 54 | ) { 55 | bridge.close(new Error('edge-core: The WebView has been unmounted.')) 56 | bridge = undefined 57 | } 58 | 59 | // If we have no bridge, start one: 60 | if (bridge == null) { 61 | let firstMessage = true 62 | bridge = new Bridge({ 63 | hideProperties, 64 | sendMessage: message => { 65 | if (debug != null) console.info(`${debug} ←`, message) 66 | if (webview == null) return 67 | 68 | const messageJs = JSON.stringify(message) 69 | .replace(/\u2028/g, '\\u2028') 70 | .replace(/\u2029/g, '\\u2029') 71 | const js = `if (window.reactBridge != null) {${ 72 | firstMessage 73 | ? 'window.reactBridge.inSync = true;' 74 | : 'window.reactBridge.inSync && ' 75 | } window.reactBridge.handleMessage(${messageJs})}` 76 | firstMessage = false 77 | 78 | UIManager.dispatchViewManagerCommand( 79 | findNodeHandle(webview), 80 | 'runJs', 81 | [js] 82 | ) 83 | }, 84 | throttleMs: YAOB_THROTTLE_MS 85 | }) 86 | 87 | // Use our inside knowledge of YAOB to directly 88 | // subscribe to the root object appearing: 89 | // @ts-expect-error 90 | onMethod.call(bridge._state, 'root', root => { 91 | gatedRoot = root 92 | tryReleasingRoot() 93 | }) 94 | } 95 | 96 | // Finally, pass the message to the bridge: 97 | bridge.handleMessage(message) 98 | } 99 | 100 | // Listen for the WebView component to mount: 101 | function setRef(element: EdgeCoreWebViewRef | null): void { 102 | webview = element 103 | tryReleasingRoot() 104 | } 105 | 106 | return { handleMessage, setRef } 107 | } 108 | -------------------------------------------------------------------------------- /src/libs.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'aes-js' 2 | 3 | declare module 'currency-codes' 4 | declare module 'ethereumjs-tx' 5 | declare module 'ethereumjs-util' 6 | declare module 'hmac-drbg' 7 | declare module 'node-fetch' 8 | 9 | declare module 'react-native' { 10 | // We can't install the React Native type definitions in node_modules, 11 | // since they conflict with the DOM ones, which we also need. 12 | // 13 | // Instead, we provide our own local definitions for the few React Native 14 | // things we use. 15 | 16 | function findNodeHandle(component: React.Component): number 17 | function requireNativeComponent(name: string): React.ComponentClass 18 | 19 | interface UIManager { 20 | dispatchViewManagerCommand: ( 21 | handle: number, 22 | method: string, 23 | args: any[] 24 | ) => void 25 | } 26 | const UIManager: UIManager 27 | } 28 | 29 | declare module 'scrypt-js' { 30 | export default function scrypt( 31 | data: ArrayLike, 32 | salt: ArrayLike, 33 | n: number, 34 | r: number, 35 | p: number, 36 | outLength: number, 37 | onProgress: ( 38 | error: Error | undefined, 39 | progress: number, 40 | key: ArrayLike | undefined 41 | ) => void 42 | ): void 43 | } 44 | -------------------------------------------------------------------------------- /src/types/exports.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EdgeContext, 3 | EdgeContextOptions, 4 | EdgeCorePlugins, 5 | EdgeCorePluginsInit, 6 | EdgeCrashReporter, 7 | EdgeFakeUser, 8 | EdgeFakeWorld, 9 | EdgeFakeWorldOptions, 10 | EdgeIo, 11 | EdgeLoginMessage, 12 | EdgeLogSettings, 13 | EdgeNativeIo, 14 | EdgeOnLog 15 | } from './types' 16 | 17 | export * from './types' 18 | 19 | export declare function addEdgeCorePlugins(plugins: EdgeCorePlugins): void 20 | export declare function lockEdgeCorePlugins(): void 21 | export declare function closeEdge(): void 22 | export declare function makeFakeIo(): EdgeIo 23 | 24 | // System-specific io exports: 25 | export declare function makeBrowserIo(): EdgeIo 26 | export declare function makeNodeIo(path: string): EdgeIo 27 | 28 | /** 29 | * Initializes the Edge core library, 30 | * automatically selecting the appropriate platform. 31 | */ 32 | export declare function makeEdgeContext( 33 | opts: EdgeContextOptions 34 | ): Promise 35 | 36 | export declare function makeFakeEdgeWorld( 37 | users?: EdgeFakeUser[], 38 | opts?: EdgeFakeWorldOptions 39 | ): Promise 40 | 41 | // --------------------------------------------------------------------- 42 | // react-native 43 | // --------------------------------------------------------------------- 44 | 45 | interface CommonProps { 46 | // Allows the Chrome debugger to attach to the Android WebView. 47 | // This is mainly useful for debugging plugins, 48 | // since the `debug` prop also activates Chrome debugging. 49 | allowDebugging?: boolean 50 | 51 | // Enable core debugging. 52 | // You must call `yarn start` in the edge-core-js project for this to work: 53 | debug?: boolean 54 | 55 | // React Native modules to pass over the bridge to the plugins: 56 | nativeIo?: EdgeNativeIo 57 | 58 | // Extra JavaScript files to load into the core as plugins. 59 | // Relative URL's resolve to the app's default asset location: 60 | pluginUris?: string[] 61 | 62 | // Called if something goes wrong when starting the core: 63 | // This will change to `(error: unknown) => void` 64 | onError?: (error: any) => unknown 65 | } 66 | 67 | export interface EdgeContextProps extends CommonProps { 68 | /** 69 | * Called once the core finishes loading. 70 | * The return type will change to `=> void` 71 | */ 72 | onLoad: (context: EdgeContext) => unknown 73 | 74 | // EdgeFakeWorldOptions: 75 | crashReporter?: EdgeCrashReporter 76 | onLog?: EdgeOnLog 77 | 78 | // EdgeContextOptions: 79 | airbitzSupport?: boolean 80 | apiKey?: string 81 | apiSecret?: Uint8Array 82 | appId?: string 83 | changeServer?: string | string[] 84 | infoServer?: string | string[] 85 | loginServer?: string | string[] // Do not include `/api` in the path 86 | syncServer?: string | string[] 87 | deviceDescription?: string 88 | hideKeys?: boolean 89 | logSettings?: Partial 90 | plugins?: EdgeCorePluginsInit 91 | skipBlockHeight?: boolean 92 | 93 | /** @deprecated Use `loginServer` instead */ 94 | authServer?: string 95 | } 96 | 97 | export interface EdgeFakeWorldProps extends CommonProps { 98 | /** 99 | * Called once the core finishes loading. 100 | * The return type will change to `=> void` 101 | */ 102 | onLoad: (world: EdgeFakeWorld) => unknown 103 | users?: EdgeFakeUser[] 104 | 105 | // EdgeFakeWorldOptions: 106 | crashReporter?: EdgeCrashReporter 107 | onLog?: EdgeOnLog 108 | } 109 | 110 | /** 111 | * We don't want this library to depend on `@types/react`, 112 | * since that isn't relevant for our Node or browser builds. 113 | */ 114 | type ComponentType = (props: Props) => { 115 | type: any 116 | props: any 117 | key: string | null 118 | } 119 | 120 | /** 121 | * React Native component for creating an EdgeContext. 122 | */ 123 | export declare const MakeEdgeContext: ComponentType 124 | 125 | /** 126 | * React Native component for creating an EdgeFakeWorld for testing. 127 | */ 128 | export declare const MakeFakeEdgeWorld: ComponentType 129 | 130 | /** 131 | * React Native function for getting login alerts without a context: 132 | */ 133 | export declare function fetchLoginMessages(apiKey: string): EdgeLoginMessage[] 134 | -------------------------------------------------------------------------------- /src/types/type-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EdgeCurrencyInfo, 3 | EdgeSwapQuote, 4 | EdgeTokenId, 5 | EdgeTokenMap, 6 | EdgeTransaction 7 | } from './types' 8 | 9 | /** 10 | * Translates a currency code to a tokenId, 11 | * and then back again for bi-directional backwards compatibility. 12 | */ 13 | export function upgradeCurrencyCode(opts: { 14 | allTokens: EdgeTokenMap 15 | currencyInfo: EdgeCurrencyInfo 16 | currencyCode?: string 17 | tokenId?: EdgeTokenId 18 | }): { currencyCode: string; tokenId: EdgeTokenId } { 19 | const { currencyInfo, allTokens } = opts 20 | 21 | // Find the tokenId: 22 | let tokenId = opts.tokenId 23 | if ( 24 | tokenId === undefined && 25 | opts.currencyCode != null && 26 | opts.currencyCode !== currencyInfo.currencyCode 27 | ) { 28 | tokenId = Object.keys(allTokens).find( 29 | tokenId => allTokens[tokenId].currencyCode === opts.currencyCode 30 | ) 31 | } 32 | 33 | // Get the currency code: 34 | const { currencyCode } = tokenId == null ? currencyInfo : allTokens[tokenId] 35 | 36 | return { currencyCode, tokenId: tokenId ?? null } 37 | } 38 | 39 | export function upgradeSwapQuote(quote: EdgeSwapQuote): EdgeSwapQuote { 40 | if (quote.networkFee != null && quote.networkFee.tokenId == null) { 41 | quote.networkFee.tokenId = quote.request.fromTokenId 42 | } 43 | return quote 44 | } 45 | 46 | export const upgradeTxNetworkFees = (tx: EdgeTransaction): void => { 47 | if (tx.networkFees == null || tx.networkFees.length === 0) { 48 | tx.networkFees = [ 49 | { 50 | tokenId: tx.tokenId, 51 | nativeAmount: tx.networkFee 52 | } 53 | ] 54 | if (tx.parentNetworkFee != null) { 55 | tx.networkFees.push({ tokenId: null, nativeAmount: tx.parentNetworkFee }) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/util/asMap.ts: -------------------------------------------------------------------------------- 1 | import { asCodec, asObject, Cleaner } from 'cleaners' 2 | 3 | import { EdgeTokenId } from '../browser' 4 | 5 | /** 6 | * Reads a JSON-style object into a JavaScript `Map` object with string keys. 7 | */ 8 | export function asMap(cleaner: Cleaner): Cleaner> { 9 | const asJsonObject = asObject(cleaner) 10 | 11 | return asCodec( 12 | raw => { 13 | const clean = asJsonObject(raw) 14 | const out = new Map() 15 | for (const key of Object.keys(clean)) out.set(key, clean[key]) 16 | return out 17 | }, 18 | clean => { 19 | const out: { [key: string]: T } = {} 20 | clean.forEach((value, key) => { 21 | out[key] = value 22 | }) 23 | return asJsonObject(out) 24 | } 25 | ) 26 | } 27 | 28 | /** 29 | * Reads a JSON-style object into a JavaScript `Map` object 30 | * with EdgeTokenId keys. 31 | */ 32 | export function asTokenIdMap( 33 | cleaner: Cleaner 34 | ): Cleaner> { 35 | const asJsonObject = asObject(cleaner) 36 | 37 | return asCodec( 38 | raw => { 39 | const clean = asJsonObject(raw) 40 | const out = new Map() 41 | for (const key of Object.keys(clean)) { 42 | out.set(key === '' ? null : key, clean[key]) 43 | } 44 | return out 45 | }, 46 | clean => { 47 | const out: { [key: string]: T } = {} 48 | clean.forEach((value, key) => { 49 | out[key == null ? '' : key] = value 50 | }) 51 | return asJsonObject(out) 52 | } 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/util/asyncWaterfall.ts: -------------------------------------------------------------------------------- 1 | import { snooze } from './snooze' 2 | 3 | type AsyncFunction = () => Promise 4 | 5 | export async function asyncWaterfall( 6 | asyncFuncs: AsyncFunction[], 7 | timeoutMs: number = 5000 8 | ): Promise { 9 | let pending = asyncFuncs.length 10 | const promises: Array> = [] 11 | for (const func of asyncFuncs) { 12 | const index = promises.length 13 | promises.push( 14 | func().catch(e => { 15 | e.index = index 16 | throw e 17 | }) 18 | ) 19 | if (pending > 1) { 20 | promises.push( 21 | new Promise(resolve => { 22 | snooze(timeoutMs) 23 | .then(() => { 24 | resolve('async_waterfall_timed_out') 25 | }) 26 | .catch(_ => {}) 27 | }) 28 | ) 29 | } 30 | try { 31 | const result = await Promise.race(promises) 32 | if (result === 'async_waterfall_timed_out') { 33 | const p = promises.pop() 34 | p?.catch(_ => {}) 35 | --pending 36 | } else { 37 | return result 38 | } 39 | } catch (e: any) { 40 | const i = e.index 41 | promises.splice(i, 1) 42 | const p = promises.pop() 43 | p?.catch(_ => {}) 44 | --pending 45 | if (pending === 0) { 46 | throw e 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/util/compare.ts: -------------------------------------------------------------------------------- 1 | const TYPED_ARRAYS: { [name: string]: boolean } = { 2 | '[object Float32Array]': true, 3 | '[object Float64Array]': true, 4 | '[object Int16Array]': true, 5 | '[object Int32Array]': true, 6 | '[object Int8Array]': true, 7 | '[object Uint16Array]': true, 8 | '[object Uint32Array]': true, 9 | '[object Uint8Array]': true, 10 | '[object Uint8ClampedArray]': true 11 | } 12 | 13 | /** 14 | * Compares two objects that are already known to have a common `[[Class]]`. 15 | */ 16 | function compareObjects(a: any, b: any, type: string): boolean { 17 | // User-created objects: 18 | if (type === '[object Object]') { 19 | const proto = Object.getPrototypeOf(a) 20 | if (proto !== Object.getPrototypeOf(b)) return false 21 | 22 | const keys = Object.getOwnPropertyNames(a) 23 | if (keys.length !== Object.getOwnPropertyNames(b).length) return false 24 | 25 | // We know that both objects have the same number of properties, 26 | // so if every property in `a` has a matching property in `b`, 27 | // the objects must be identical, regardless of key order. 28 | for (const key of keys) { 29 | if ( 30 | !Object.prototype.hasOwnProperty.call(b, key) || 31 | !compare(a[key], b[key]) 32 | ) { 33 | return false 34 | } 35 | } 36 | return true 37 | } 38 | 39 | // Arrays: 40 | if (type === '[object Array]') { 41 | if (a.length !== b.length) return false 42 | for (let i = 0; i < a.length; ++i) { 43 | if (!compare(a[i], b[i])) return false 44 | } 45 | return true 46 | } 47 | 48 | // Javascript dates: 49 | if (type === '[object Date]') { 50 | return a.getTime() === b.getTime() 51 | } 52 | 53 | if (type === '[object Map]') { 54 | return compareMap(a, b) 55 | } 56 | 57 | // Typed arrays: 58 | if (TYPED_ARRAYS[type]) { 59 | if (a.length !== b.length) return false 60 | for (let i = 0; i < a.length; ++i) { 61 | if (a[i] !== b[i]) return false 62 | } 63 | return true 64 | } 65 | 66 | // We don't even try comparing anything else: 67 | return false 68 | } 69 | 70 | /** 71 | * Compare Maps 72 | */ 73 | function compareMap(map1: Map, map2: Map): boolean { 74 | if (map1.size !== map2.size) { 75 | return false 76 | } 77 | 78 | for (const [key, value] of map1) { 79 | if (!map2.has(key) || map2.get(key) !== value) { 80 | return false 81 | } 82 | } 83 | 84 | return true 85 | } 86 | /** 87 | * Returns true if two Javascript values are equal in value. 88 | */ 89 | export function compare(a: any, b: any): boolean { 90 | if (a === b) return true 91 | 92 | // Fast path for primitives: 93 | if (typeof a !== 'object') return false 94 | if (typeof b !== 'object') return false 95 | 96 | // If these are objects, the internal `[[Class]]` properties must match: 97 | const type = Object.prototype.toString.call(a) 98 | if (type !== Object.prototype.toString.call(b)) return false 99 | 100 | return compareObjects(a, b, type) 101 | } 102 | -------------------------------------------------------------------------------- /src/util/crypto/crypto.ts: -------------------------------------------------------------------------------- 1 | import aesjs from 'aes-js' 2 | 3 | import { EdgeBox } from '../../types/server-types' 4 | import { EdgeIo } from '../../types/types' 5 | import { utf8 } from '../encoding' 6 | import { sha256 } from './hashes' 7 | import { verifyData } from './verify' 8 | 9 | const AesCbc = aesjs.ModeOfOperation.cbc 10 | 11 | /** 12 | * Some of our data contains terminating null bytes due to an old bug, 13 | * so this function handles text decryption as a special case. 14 | */ 15 | export function decryptText(box: EdgeBox, key: Uint8Array): string { 16 | const data = decrypt(box, key) 17 | if (data[data.length - 1] === 0) { 18 | return utf8.stringify(data.subarray(0, -1)) 19 | } 20 | return utf8.stringify(data) 21 | } 22 | 23 | /** 24 | * @param box an Airbitz JSON encryption box 25 | * @param key a key, as an ArrayBuffer 26 | */ 27 | export function decrypt(box: EdgeBox, key: Uint8Array): Uint8Array { 28 | // Check JSON: 29 | if (box.encryptionType !== 0) { 30 | throw new Error('Unknown encryption type') 31 | } 32 | const iv = box.iv_hex 33 | const ciphertext = box.data_base64 34 | 35 | // Decrypt: 36 | const cipher = new AesCbc(key, iv) 37 | const raw: Uint8Array = cipher.decrypt(ciphertext) 38 | 39 | // Calculate data locations: 40 | const headerStart = 1 41 | const headerSize = raw[0] 42 | const dataStart = headerStart + headerSize + 4 43 | const dataSize = 44 | (raw[dataStart - 4] << 24) | 45 | (raw[dataStart - 3] << 16) | 46 | (raw[dataStart - 2] << 8) | 47 | raw[dataStart - 1] 48 | const footerStart = dataStart + dataSize + 1 49 | const footerSize = raw[footerStart - 1] 50 | const hashStart = footerStart + footerSize 51 | const paddingStart = hashStart + 32 52 | 53 | // Verify SHA-256 checksum: 54 | const hash = sha256(raw.subarray(0, hashStart)) 55 | if (!verifyData(hash, raw.subarray(hashStart, paddingStart))) { 56 | throw new Error('Invalid checksum') 57 | } 58 | 59 | // Verify pkcs7 padding: 60 | const padding = pkcs7(paddingStart) 61 | if (!verifyData(padding, raw.subarray(paddingStart))) { 62 | throw new Error('Invalid PKCS7 padding') 63 | } 64 | 65 | // Return the payload: 66 | return raw.subarray(dataStart, dataStart + dataSize) 67 | } 68 | 69 | /** 70 | * @param payload an ArrayBuffer of data 71 | * @param key a key, as an ArrayBuffer 72 | */ 73 | export function encrypt( 74 | io: EdgeIo, 75 | data: Uint8Array, 76 | key: Uint8Array 77 | ): EdgeBox { 78 | // Calculate data locations: 79 | const headerStart = 1 80 | const headerSize = io.random(1)[0] & 0x1f 81 | const dataStart = headerStart + headerSize + 4 82 | const dataSize = data.length 83 | const footerStart = dataStart + dataSize + 1 84 | const footerSize = io.random(1)[0] & 0x1f 85 | const hashStart = footerStart + footerSize 86 | const paddingStart = hashStart + 32 87 | 88 | // Initialize the buffer with padding: 89 | const padding = pkcs7(paddingStart) 90 | const raw = new Uint8Array(paddingStart + padding.length) 91 | raw.set(padding, paddingStart) 92 | 93 | // Add header: 94 | raw[0] = headerSize 95 | raw.set(io.random(headerSize), headerStart) 96 | 97 | // Add payload: 98 | raw[dataStart - 4] = (dataSize >> 24) & 0xff 99 | raw[dataStart - 3] = (dataSize >> 16) & 0xff 100 | raw[dataStart - 2] = (dataSize >> 8) & 0xff 101 | raw[dataStart - 1] = dataSize & 0xff 102 | raw.set(data, dataStart) 103 | 104 | // Add footer: 105 | raw[footerStart - 1] = footerSize 106 | raw.set(io.random(footerSize), footerStart) 107 | 108 | // Add SHA-256 checksum: 109 | raw.set(sha256(raw.subarray(0, hashStart)), hashStart) 110 | 111 | // Encrypt to JSON: 112 | const iv = io.random(16) 113 | const cipher = new AesCbc(key, iv) 114 | const ciphertext = cipher.encrypt(raw) 115 | return { 116 | encryptionType: 0, 117 | iv_hex: iv, 118 | data_base64: ciphertext 119 | } 120 | } 121 | 122 | /** 123 | * Generates the pkcs7 padding data that should be appended to 124 | * data of a particular length. 125 | */ 126 | function pkcs7(length: number): Uint8Array { 127 | const out = new Uint8Array(16 - (length & 0xf)) 128 | for (let i = 0; i < out.length; ++i) out[i] = out.length 129 | return out 130 | } 131 | -------------------------------------------------------------------------------- /src/util/crypto/hashes.ts: -------------------------------------------------------------------------------- 1 | import hashjs from 'hash.js' 2 | 3 | export function hmacSha1(data: Uint8Array, key: Uint8Array): Uint8Array { 4 | // @ts-expect-error 5 | const hmac = hashjs.hmac(hashjs.sha1, key) 6 | return Uint8Array.from(hmac.update(data).digest()) 7 | } 8 | 9 | export function hmacSha256(data: Uint8Array, key: Uint8Array): Uint8Array { 10 | // @ts-expect-error 11 | const hmac = hashjs.hmac(hashjs.sha256, key) 12 | return Uint8Array.from(hmac.update(data).digest()) 13 | } 14 | 15 | export function hmacSha512(data: Uint8Array, key: Uint8Array): Uint8Array { 16 | // @ts-expect-error 17 | const hmac = hashjs.hmac(hashjs.sha512, key) 18 | return Uint8Array.from(hmac.update(data).digest()) 19 | } 20 | 21 | export function sha256(data: Uint8Array): Uint8Array { 22 | const hash = hashjs.sha256() 23 | return Uint8Array.from(hash.update(data).digest()) 24 | } 25 | -------------------------------------------------------------------------------- /src/util/crypto/hotp.ts: -------------------------------------------------------------------------------- 1 | import { hmacSha1 } from './hashes' 2 | 3 | export function numberToBe64(number: number): Uint8Array { 4 | const high = Math.floor(number / 0x100000000) 5 | return new Uint8Array([ 6 | (high >> 24) & 0xff, 7 | (high >> 16) & 0xff, 8 | (high >> 8) & 0xff, 9 | high & 0xff, 10 | (number >> 24) & 0xff, 11 | (number >> 16) & 0xff, 12 | (number >> 8) & 0xff, 13 | number & 0xff 14 | ]) 15 | } 16 | 17 | /** 18 | * Implements the rfc4226 HOTP specification. 19 | * @param {*} secret The secret value, K, from rfc4226 20 | * @param {*} counter The counter, C, from rfc4226 21 | * @param {*} digits The number of digits to generate 22 | */ 23 | export function hotp( 24 | secret: Uint8Array, 25 | counter: number, 26 | digits: number 27 | ): string { 28 | const hmac = hmacSha1(numberToBe64(counter), secret) 29 | 30 | const offset = hmac[19] & 0xf 31 | const p = 32 | ((hmac[offset] & 0x7f) << 24) | 33 | (hmac[offset + 1] << 16) | 34 | (hmac[offset + 2] << 8) | 35 | hmac[offset + 3] 36 | const text = p.toString() 37 | 38 | const padding = Array(digits).join('0') 39 | return (padding + text).slice(-digits) 40 | } 41 | 42 | /** 43 | * Generates an HOTP code based on the current time. 44 | */ 45 | export function totp( 46 | secret: Uint8Array, 47 | now: number = Date.now() / 1000 48 | ): string { 49 | return hotp(secret, now / 30, 6) 50 | } 51 | 52 | /** 53 | * Validates a TOTP code based on the current time, 54 | * within an adjustable range. 55 | */ 56 | export function checkTotp( 57 | secret: Uint8Array, 58 | otp: string, 59 | opts: { now?: number; spread?: number } = {} 60 | ): boolean { 61 | const { now = Date.now() / 1000, spread = 1 } = opts 62 | const index = now / 30 63 | 64 | // Try the middle: 65 | if (otp === hotp(secret, index, 6)) return true 66 | 67 | // Spiral outwards: 68 | for (let i = 1; i <= spread; ++i) { 69 | if (otp === hotp(secret, index - i, 6)) return true 70 | if (otp === hotp(secret, index + i, 6)) return true 71 | } 72 | return false 73 | } 74 | -------------------------------------------------------------------------------- /src/util/crypto/scrypt.ts: -------------------------------------------------------------------------------- 1 | import scryptJs from 'scrypt-js' 2 | 3 | export function scrypt( 4 | data: Uint8Array, 5 | salt: Uint8Array, 6 | n: number, 7 | r: number, 8 | p: number, 9 | dklen: number 10 | ): Promise { 11 | return new Promise((resolve, reject) => { 12 | // The scrypt library will crash if it gets a Uint8Array > 64 bytes: 13 | const copy: number[] = [] 14 | for (let i = 0; i < data.length; ++i) copy[i] = data[i] 15 | 16 | scryptJs(copy, salt, n, r, p, dklen, (error, progress, key) => { 17 | if (error != null) return reject(error) 18 | if (key != null) return resolve(Uint8Array.from(key)) 19 | }) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/util/crypto/verify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compares two byte arrays without data-dependent branches. 3 | * Returns true if they match. 4 | */ 5 | export function verifyData(a: Uint8Array, b: Uint8Array): boolean { 6 | const length = a.length 7 | if (length !== b.length) return false 8 | 9 | let out = 0 10 | for (let i = 0; i < length; ++i) out |= a[i] ^ b[i] 11 | return out === 0 12 | } 13 | -------------------------------------------------------------------------------- /src/util/encoding.ts: -------------------------------------------------------------------------------- 1 | import baseX from 'base-x' 2 | 3 | const base58Codec = baseX( 4 | '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 5 | ) 6 | 7 | export const base58 = { 8 | parse(text: string): Uint8Array { 9 | return base58Codec.decode(text) 10 | }, 11 | stringify(data: Uint8Array | number[]): string { 12 | return base58Codec.encode(data) 13 | } 14 | } 15 | 16 | export const utf8 = { 17 | parse(text: string): Uint8Array { 18 | const byteString = encodeURI(text) 19 | const out = new Uint8Array(byteString.length) 20 | 21 | // Treat each character as a byte, except for %XX escape sequences: 22 | let di = 0 // Destination index 23 | for (let i = 0; i < byteString.length; ++i) { 24 | const c = byteString.charCodeAt(i) 25 | if (c === 0x25) { 26 | out[di++] = parseInt(byteString.slice(i + 1, i + 3), 16) 27 | i += 2 28 | } else { 29 | out[di++] = c 30 | } 31 | } 32 | 33 | // Trim any over-allocated space (zero-copy): 34 | return out.subarray(0, di) 35 | }, 36 | 37 | stringify(data: Uint8Array | number[]): string { 38 | // Create a %XX escape sequence for each input byte: 39 | let byteString = '' 40 | for (let i = 0; i < data.length; ++i) { 41 | const byte = data[i] 42 | byteString += '%' + (byte >> 4).toString(16) + (byte & 0xf).toString(16) 43 | } 44 | 45 | return decodeURIComponent(byteString) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/util/file-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Cleaner, uncleaner } from 'cleaners' 2 | import { Disklet } from 'disklet' 3 | 4 | /** 5 | * A wrapper that knows how to load and save JSON files, 6 | * with parsing, stringifying, and cleaning. 7 | */ 8 | export interface JsonFile { 9 | load: (disklet: Disklet, path: string) => Promise 10 | save: (disklet: Disklet, path: string, data: T) => Promise 11 | } 12 | 13 | export function makeJsonFile(cleaner: Cleaner): JsonFile { 14 | const wasData = uncleaner(cleaner) 15 | return { 16 | async load(disklet, path) { 17 | try { 18 | return cleaner(JSON.parse(await disklet.getText(path))) 19 | } catch (error: unknown) {} 20 | }, 21 | async save(disklet, path, data) { 22 | await disklet.setText(path, JSON.stringify(wasData(data))) 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * A cleaner for something that must be an object, 29 | * but we don't care about the keys inside: 30 | */ 31 | export const asJsonObject: Cleaner = raw => { 32 | if (raw == null || typeof raw !== 'object') { 33 | throw new TypeError('Expected a JSON object') 34 | } 35 | return raw 36 | } 37 | -------------------------------------------------------------------------------- /src/util/match-json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compares two JSON-like objects, returning false if they differ. 3 | */ 4 | export function matchJson(a: any, b: any): boolean { 5 | // Use simple equality, unless a and b are proper objects: 6 | if ( 7 | typeof a !== 'object' || 8 | typeof b !== 'object' || 9 | a == null || 10 | b == null 11 | ) { 12 | return a === b 13 | } 14 | 15 | // These must either be both arrays or both objects: 16 | const aIsArray = Array.isArray(a) 17 | const bIsArray = Array.isArray(b) 18 | if (aIsArray !== bIsArray) return false 19 | 20 | // Compare arrays in order: 21 | if (aIsArray) { 22 | if (a.length !== b.length) return false 23 | for (let i = 0; i < a.length; ++i) { 24 | if (!matchJson(a[i], b[i])) return false 25 | } 26 | return true 27 | } 28 | 29 | // These are both regular objects, so grab the keys, 30 | // ignoring entries where the value is `undefined`: 31 | const aKeys = Object.getOwnPropertyNames(a).filter( 32 | key => a[key] !== undefined 33 | ) 34 | const bKeys = Object.getOwnPropertyNames(b).filter( 35 | key => b[key] !== undefined 36 | ) 37 | if (aKeys.length !== bKeys.length) return false 38 | 39 | // We know that both objects have the same number of properties, 40 | // so if every property in `a` has a matching property in `b`, 41 | // the objects must be identical, regardless of key order. 42 | for (const key of aKeys) { 43 | if (!Object.prototype.hasOwnProperty.call(b, key)) return false 44 | if (!matchJson(a[key], b[key])) return false 45 | } 46 | return true 47 | } 48 | -------------------------------------------------------------------------------- /src/util/periodic-task.ts: -------------------------------------------------------------------------------- 1 | interface PeriodicTaskOptions { 2 | // Handles any errors that the task throws or rejects with: 3 | onError?: (error: unknown) => void 4 | } 5 | 6 | interface StartOptions { 7 | // True to start in the waiting state, skipping the first run, 8 | // or the number of ms to wait before the first run: 9 | wait?: boolean | number 10 | } 11 | 12 | export interface PeriodicTask { 13 | setDelay: (milliseconds: number) => void 14 | start: (opts?: StartOptions) => void 15 | stop: () => void 16 | 17 | // True once start is called, false after stop is called: 18 | readonly started: boolean 19 | } 20 | 21 | /** 22 | * Schedule a repeating task, with the specified gap between runs. 23 | */ 24 | export function makePeriodicTask( 25 | task: () => Promise | undefined, 26 | msGap: number, 27 | opts: PeriodicTaskOptions = {} 28 | ): PeriodicTask { 29 | const { onError = () => {} } = opts 30 | 31 | // A started task will keep bouncing between running & waiting. 32 | // The `running` flag will be true in the running state, 33 | // and `timeout` will have a value in the waiting state. 34 | let running = false 35 | let timeout: ReturnType | undefined 36 | 37 | function startRunning(): void { 38 | timeout = undefined 39 | if (!out.started) return 40 | running = true 41 | new Promise(resolve => resolve(task())) 42 | .catch(onError) 43 | .then(resumeWaiting, resumeWaiting) 44 | } 45 | 46 | function startWaiting(nextGap: number): void { 47 | running = false 48 | if (!out.started) return 49 | timeout = setTimeout(startRunning, nextGap) 50 | } 51 | 52 | function resumeWaiting(): void { 53 | startWaiting(msGap) 54 | } 55 | 56 | const out = { 57 | started: false, 58 | 59 | setDelay(milliseconds: number): void { 60 | msGap = milliseconds 61 | }, 62 | 63 | start(opts: StartOptions = {}): void { 64 | const { wait } = opts 65 | out.started = true 66 | if (!running && timeout == null) { 67 | if (typeof wait === 'number') startWaiting(wait) 68 | else if (wait === true) startWaiting(msGap) 69 | else startRunning() 70 | } 71 | }, 72 | 73 | stop(): void { 74 | out.started = false 75 | if (timeout != null) { 76 | clearTimeout(timeout) 77 | timeout = undefined 78 | } 79 | } 80 | } 81 | return out 82 | } 83 | -------------------------------------------------------------------------------- /src/util/promise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Waits for the first successful promise. 3 | * If no promise succeeds, returns the last failure. 4 | */ 5 | export function anyPromise(promises: Array>): Promise { 6 | return new Promise((resolve, reject) => { 7 | let failed = 0 8 | for (const promise of promises) { 9 | promise.then(resolve, error => { 10 | if (++failed >= promises.length) reject(error) 11 | }) 12 | } 13 | }) 14 | } 15 | 16 | /** 17 | * If the promise doesn't resolve in the given time, 18 | * reject it with the provided error, or a generic error if none is provided. 19 | */ 20 | export function timeout( 21 | promise: Promise, 22 | ms: number, 23 | error: Error = new Error(`Timeout of ${ms}ms exceeded`) 24 | ): Promise { 25 | return new Promise((resolve, reject) => { 26 | const timer = setTimeout(() => reject(error), ms) 27 | promise.then( 28 | ok => { 29 | clearTimeout(timer) 30 | resolve(ok) 31 | }, 32 | error => { 33 | clearTimeout(timer) 34 | reject(error) 35 | } 36 | ) 37 | }) 38 | } 39 | 40 | /** 41 | * Waits for a collection of promises. 42 | * Returns all the promises that manage to resolve within the timeout. 43 | * If no promises mange to resolve within the timeout, 44 | * returns the first promise that resolves. 45 | * If all promises reject, rejects an array of errors. 46 | */ 47 | export function fuzzyTimeout( 48 | promises: Array>, 49 | timeoutMs: number 50 | ): Promise<{ results: T[]; errors: unknown[] }> { 51 | return new Promise((resolve, reject) => { 52 | let done = false 53 | const results: T[] = [] 54 | const errors: unknown[] = [] 55 | 56 | // Set up the timer: 57 | let timer: ReturnType | undefined = setTimeout(() => { 58 | timer = undefined 59 | if (results.length > 0) { 60 | done = true 61 | resolve({ results, errors }) 62 | } 63 | }, timeoutMs) 64 | 65 | function checkEnd(): void { 66 | const allDone = results.length + errors.length === promises.length 67 | if (allDone && timer != null) { 68 | clearTimeout(timer) 69 | } 70 | if (allDone || timer == null) { 71 | done = true 72 | if (results.length > 0) resolve({ results, errors }) 73 | else reject(errors) 74 | } 75 | } 76 | checkEnd() // Handle empty lists 77 | 78 | // Attach to the promises: 79 | for (const promise of promises) { 80 | promise.then( 81 | result => { 82 | if (done) return 83 | results.push(result) 84 | checkEnd() 85 | }, 86 | failure => { 87 | if (done) return 88 | errors.push(failure) 89 | checkEnd() 90 | } 91 | ) 92 | } 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /src/util/shuffle.ts: -------------------------------------------------------------------------------- 1 | export function shuffle(arr: T[]): T[] { 2 | for (let i = arr.length - 1; i > 0; i--) { 3 | const j = Math.floor(Math.random() * (i + 1)) 4 | ;[arr[i], arr[j]] = [arr[j], arr[i]] 5 | } 6 | return arr 7 | } 8 | -------------------------------------------------------------------------------- /src/util/snooze.ts: -------------------------------------------------------------------------------- 1 | export function snooze(ms: number): Promise { 2 | return new Promise(resolve => setTimeout(() => resolve(ms), ms)) 3 | } 4 | -------------------------------------------------------------------------------- /src/util/updateQueue.ts: -------------------------------------------------------------------------------- 1 | // How often to run jobs from the queue 2 | let QUEUE_RUN_DELAY = 500 3 | 4 | // How many jobs to run from the queue on each cycle 5 | let QUEUE_JOBS_PER_RUN = 3 6 | 7 | interface UpdateQueue { 8 | id: string 9 | action: string 10 | updateFunc: Function 11 | } 12 | 13 | const updateQueue: UpdateQueue[] = [] 14 | let timeout: ReturnType | undefined 15 | 16 | export function enableTestMode(): void { 17 | QUEUE_JOBS_PER_RUN = 99 18 | QUEUE_RUN_DELAY = 1 19 | } 20 | 21 | export function pushUpdate(update: UpdateQueue): void { 22 | if (updateQueue.length <= 0) { 23 | startQueue() 24 | } 25 | let didUpdate = false 26 | for (const u of updateQueue) { 27 | if (u.id === update.id && u.action === update.action) { 28 | u.updateFunc = update.updateFunc 29 | didUpdate = true 30 | break 31 | } 32 | } 33 | if (!didUpdate) { 34 | updateQueue.push(update) 35 | } 36 | } 37 | 38 | export function removeIdFromQueue(id: string): void { 39 | for (let i = 0; i < updateQueue.length; i++) { 40 | const update = updateQueue[i] 41 | if (id === update.id) { 42 | updateQueue.splice(i, 1) 43 | break 44 | } 45 | } 46 | if (updateQueue.length <= 0 && timeout != null) { 47 | clearTimeout(timeout) 48 | } 49 | } 50 | 51 | function startQueue(): void { 52 | timeout = setTimeout(() => { 53 | const numJobs = Math.min(QUEUE_JOBS_PER_RUN, updateQueue.length) 54 | for (let i = 0; i < numJobs; i++) { 55 | const u = updateQueue.shift() 56 | if (u != null) u.updateFunc() 57 | } 58 | if (updateQueue.length > 0) { 59 | startQueue() 60 | } 61 | }, QUEUE_RUN_DELAY) 62 | } 63 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safely concatenate a bunch of arrays, which may or may not exist. 3 | * Purrs quietly when pet. 4 | */ 5 | export function softCat(...lists: Array): T[] { 6 | const out: T[] = [] 7 | return out.concat(...lists.filter((list): list is T[] => list != null)) 8 | } 9 | 10 | /** 11 | * Like `Object.assign`, but makes the properties non-enumerable. 12 | */ 13 | export function addHiddenProperties( 14 | object: O, 15 | properties: P 16 | ): O & P { 17 | for (const name of Object.keys(properties)) { 18 | Object.defineProperty(object, name, { 19 | writable: true, 20 | configurable: true, 21 | // @ts-expect-error 22 | value: properties[name] 23 | }) 24 | } 25 | return object as O & P 26 | } 27 | -------------------------------------------------------------------------------- /src/util/validateServer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * We only accept *.edge.app or localhost as valid domain names. 3 | */ 4 | export function validateServer(server: string): void { 5 | const url = new URL(server) 6 | 7 | if (url.protocol === 'http:' || url.protocol === 'ws:') { 8 | if (url.hostname === 'localhost') return 9 | } 10 | if (url.protocol === 'https:' || url.protocol === 'wss:') { 11 | if (url.hostname === 'localhost') return 12 | if (/^([A-Za-z0-9_-]+\.)*edge(test)?\.app$/.test(url.hostname)) return 13 | } 14 | 15 | throw new Error( 16 | `Only *.edge.app or localhost are valid login domain names, not ${url.hostname}` 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /test/core/account/data-store.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { makeFakeEdgeWorld } from '../../../src/index' 5 | import { expectRejection } from '../../expect-rejection' 6 | import { fakeUser } from '../../fake/fake-user' 7 | 8 | const contextOptions = { apiKey: '', appId: '' } 9 | const quiet = { onLog() {} } 10 | 11 | describe('data store API', function () { 12 | it('stores data', async function () { 13 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 14 | const context = await world.makeEdgeContext(contextOptions) 15 | const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) 16 | 17 | const storeId = 'localdogecoin' 18 | 19 | // Empty to start: 20 | expect(await account.dataStore.listStoreIds()).deep.equals([]) 21 | 22 | // Set some items: 23 | await account.dataStore.setItem(storeId, 'username', 'shibe') 24 | await account.dataStore.setItem(storeId, 'password', 'm00n') 25 | 26 | // The items should be there: 27 | expect(await account.dataStore.listStoreIds()).deep.equals([storeId]) 28 | expect(await account.dataStore.listItemIds(storeId)).deep.equals([ 29 | 'username', 30 | 'password' 31 | ]) 32 | expect(await account.dataStore.getItem(storeId, 'username')).equals('shibe') 33 | expect(await account.dataStore.getItem(storeId, 'password')).equals('m00n') 34 | 35 | // Delete an item: 36 | await account.dataStore.deleteItem(storeId, 'username') 37 | await expectRejection( 38 | account.dataStore.getItem(storeId, 'username'), 39 | 'Error: No item named "username"' 40 | ) 41 | expect(await account.dataStore.listItemIds(storeId)).deep.equals([ 42 | 'password' 43 | ]) 44 | 45 | // Delete the plugin: 46 | await account.dataStore.deleteStore(storeId) 47 | expect(await account.dataStore.listStoreIds()).deep.equals([]) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/core/context/context.test.ts: -------------------------------------------------------------------------------- 1 | import '../../fake/fake-plugins' 2 | 3 | import { expect } from 'chai' 4 | import { describe, it } from 'mocha' 5 | import { base64 } from 'rfc4648' 6 | 7 | import { makeFakeEdgeWorld } from '../../../src/index' 8 | import { base58 } from '../../../src/util/encoding' 9 | import { expectRejection } from '../../expect-rejection' 10 | import { fakeUser, fakeUserDump } from '../../fake/fake-user' 11 | 12 | const contextOptions = { apiKey: '', appId: '' } 13 | const quiet = { onLog() {} } 14 | 15 | describe('context', function () { 16 | it('has basic properties', async function () { 17 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 18 | const context = await world.makeEdgeContext({ 19 | ...contextOptions, 20 | appId: 'test' 21 | }) 22 | 23 | expect(context.appId).equals('test') 24 | expect(context.clientId).match(/[0-9a-zA-Z]+/) 25 | }) 26 | 27 | it('list usernames in local storage', async function () { 28 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 29 | const context = await world.makeEdgeContext(contextOptions) 30 | 31 | expect(context.localUsers).deep.equals([ 32 | { 33 | keyLoginEnabled: true, 34 | lastLogin: fakeUser.lastLogin, 35 | loginId: 'BTnpEn7pabDXbcv7VxnKBDsn4CVSwLRA25J8U84qmg4h', 36 | pinLoginEnabled: true, 37 | recovery2Key: 'NVADGXzb5Zc55PYXVVT7GRcXPnY9NZJUjiZK8aQnidc', 38 | username: 'js test 0', 39 | voucherId: undefined 40 | } 41 | ]) 42 | }) 43 | 44 | it('remove loginId from local storage', async function () { 45 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 46 | const context = await world.makeEdgeContext(contextOptions) 47 | 48 | const loginId = base58.stringify(base64.parse(fakeUser.loginId)) 49 | expect(await context.localUsers).has.lengthOf(1) 50 | await context.forgetAccount(loginId) 51 | expect(await context.localUsers).has.lengthOf(0) 52 | }) 53 | 54 | it('cannot remove logged-in users', async function () { 55 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 56 | const context = await world.makeEdgeContext(contextOptions) 57 | await context.loginWithPIN(fakeUser.username, fakeUser.pin) 58 | 59 | const loginId = base58.stringify(base64.parse(fakeUser.loginId)) 60 | await expectRejection( 61 | context.forgetAccount(loginId), 62 | 'Error: Cannot remove logged-in user' 63 | ) 64 | }) 65 | 66 | it('dumps fake users', async function () { 67 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 68 | const context = await world.makeEdgeContext(contextOptions) 69 | const account = await context.loginWithPIN( 70 | fakeUser.username, 71 | fakeUser.pin, 72 | { now: fakeUser.lastLogin } 73 | ) 74 | 75 | // The dump should not include this new guy's repos: 76 | await context.createAccount({ 77 | username: 'dummy', 78 | pin: '1111' 79 | }) 80 | 81 | // Do the dump: 82 | const dump = await world.dumpFakeUser(account) 83 | 84 | // Get rid of extra `undefined` fields: 85 | const server = JSON.parse(JSON.stringify(dump.server)) 86 | 87 | // The PIN login upgrades the account, so the dump will have extra stuff: 88 | expect(server.loginAuthBox != null).equals(true) 89 | expect(server.loginAuth != null).equals(true) 90 | delete server.loginAuthBox 91 | delete server.loginAuth 92 | 93 | expect({ ...dump, server }).deep.equals(fakeUserDump) 94 | 95 | // require('fs').writeFileSync('./fake-user.json', JSON.stringify(dump)) 96 | }) 97 | }) 98 | 99 | describe('username', function () { 100 | it('normalize spaces and capitalization', async function () { 101 | const world = await makeFakeEdgeWorld([], quiet) 102 | const context = await world.makeEdgeContext(contextOptions) 103 | 104 | expect(context.fixUsername(' TEST TEST ')).equals('test test') 105 | }) 106 | 107 | it('reject invalid characters', async function () { 108 | const world = await makeFakeEdgeWorld([], quiet) 109 | const context = await world.makeEdgeContext(contextOptions) 110 | 111 | expect(() => context.fixUsername('テスト')).to.throw() 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /test/core/currency/confirmations.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { determineConfirmations } from '../../../src/core/currency/wallet/currency-wallet-callbacks' 5 | 6 | describe('confirmations API', function () { 7 | const helper = ( 8 | txBlockHeight: number, 9 | netBlockHeight: number, 10 | required: number, 11 | expected: string 12 | ): void => { 13 | const tx = { blockHeight: txBlockHeight } 14 | expect( 15 | determineConfirmations(tx, netBlockHeight, required), 16 | `Expected tx with blockHeight of ${txBlockHeight} to be ${expected} at network blockHeight ${netBlockHeight} with ${required} required confs` 17 | ).equals(expected) 18 | } 19 | 20 | it('correctly resolves to unconfirmed', function () { 21 | const txBlockHeight = 0 22 | for (let blockHeight = 1; blockHeight <= 100; ++blockHeight) { 23 | for (let required = 1; required <= 10; ++required) { 24 | helper(txBlockHeight, blockHeight, required, 'unconfirmed') 25 | } 26 | } 27 | }) 28 | it('correctly resolves to confirmed', function () { 29 | for (let required = 0; required <= 10; ++required) { 30 | for ( 31 | let blockHeight = 100; 32 | blockHeight <= 100 + required; 33 | ++blockHeight 34 | ) { 35 | const txBlockHeight = blockHeight - Math.max(0, required - 1) // Subtract 1 because same blockHeights counts as 1 conf 36 | helper(txBlockHeight, blockHeight, required, 'confirmed') 37 | } 38 | } 39 | }) 40 | it('correctly resolves to syncing', function () { 41 | const txBlockHeight = 1000 42 | for (let blockHeight = -1; blockHeight <= 100; ++blockHeight) { 43 | for (let required = 0; required <= 10; ++required) { 44 | helper(0, blockHeight, required, 'unconfirmed') 45 | helper(txBlockHeight, blockHeight, required, 'syncing') 46 | } 47 | } 48 | }) 49 | it('correctly resolves to dropped', function () { 50 | const txBlockHeight = -1 51 | for (let blockHeight = -1; blockHeight <= 100; ++blockHeight) { 52 | for (let required = 0; required <= 10; ++required) { 53 | helper(txBlockHeight, blockHeight, required, 'dropped') 54 | } 55 | } 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/core/currency/currency.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { getCurrencyMultiplier } from '../../../src/core/currency/currency-selectors' 5 | import { fakeCurrencyPlugin } from '../../fake/fake-currency-plugin' 6 | 7 | describe('currency selectors', function () { 8 | it('find currency multiplier', async function () { 9 | const { currencyInfo } = fakeCurrencyPlugin 10 | const tokens = 11 | fakeCurrencyPlugin.getBuiltinTokens != null 12 | ? await fakeCurrencyPlugin.getBuiltinTokens() 13 | : {} 14 | 15 | expect(getCurrencyMultiplier(currencyInfo, tokens, 'SMALL')).equals('10') 16 | expect(getCurrencyMultiplier(currencyInfo, tokens, 'FAKE')).equals('100') 17 | expect(getCurrencyMultiplier(currencyInfo, tokens, 'TOKEN')).equals('1000') 18 | expect(getCurrencyMultiplier(currencyInfo, tokens, '-error-')).equals('1') 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/core/login/airbitz.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { makeFakeEdgeWorld } from '../../../src/index' 5 | import { airbitzFiles, fakeUser } from '../../fake/fake-user' 6 | 7 | const quiet = { onLog() {} } 8 | 9 | describe('airbitz stashes', function () { 10 | it('can log into legacy airbitz files', async function () { 11 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 12 | const context = await world.makeEdgeContext({ 13 | airbitzSupport: true, 14 | apiKey: '', 15 | appId: '', 16 | cleanDevice: true, 17 | extraFiles: airbitzFiles 18 | }) 19 | 20 | expect(context.localUsers).deep.equals([ 21 | { 22 | keyLoginEnabled: true, 23 | lastLogin: undefined, 24 | loginId: 'BTnpEn7pabDXbcv7VxnKBDsn4CVSwLRA25J8U84qmg4h', 25 | pinLoginEnabled: true, 26 | recovery2Key: 'NVADGXzb5Zc55PYXVVT7GRcXPnY9NZJUjiZK8aQnidc', 27 | username: 'js test 0', 28 | voucherId: undefined 29 | } 30 | ]) 31 | 32 | await context.loginWithPIN(fakeUser.username, fakeUser.pin) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/core/login/edge.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { 5 | EdgeAccount, 6 | EdgeFakeWorld, 7 | EdgeLobby, 8 | EdgePendingEdgeLogin, 9 | makeFakeEdgeWorld 10 | } from '../../../src/index' 11 | import { fakeUser } from '../../fake/fake-user' 12 | 13 | const contextOptions = { apiKey: '', appId: '' } 14 | const quiet = { onLog() {} } 15 | 16 | async function simulateRemoteApproval( 17 | world: EdgeFakeWorld, 18 | lobbyId: string 19 | ): Promise { 20 | const context = await world.makeEdgeContext(contextOptions) 21 | const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) 22 | 23 | const lobby: EdgeLobby = await account.fetchLobby(lobbyId) 24 | const { loginRequest } = lobby 25 | if (loginRequest == null) throw new Error('No login request') 26 | expect(loginRequest.appId).equals('test-child') 27 | 28 | await loginRequest.approve() 29 | } 30 | 31 | describe('edge login', function () { 32 | it('works with local events', async function () { 33 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 34 | const context = await world.makeEdgeContext({ 35 | ...contextOptions, 36 | appId: 'test-child', 37 | cleanDevice: true 38 | }) 39 | 40 | const pending: EdgePendingEdgeLogin = await context.requestEdgeLogin() 41 | const out: Promise = new Promise((resolve, reject) => { 42 | pending.watch('state', state => { 43 | if (state === 'done' && pending.account != null) { 44 | resolve(pending.account) 45 | } 46 | if (state === 'error') reject(pending.error) 47 | }) 48 | }) 49 | 50 | await simulateRemoteApproval(world, pending.id) 51 | const account = await out 52 | expect(account.appId).equals('test-child') 53 | 54 | await context.loginWithPIN(fakeUser.username, fakeUser.pin) 55 | }) 56 | 57 | it('cancel', async function () { 58 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 59 | const context = await world.makeEdgeContext(contextOptions) 60 | 61 | const pendingLogin = await context.requestEdgeLogin() 62 | 63 | // All we can verify here is that cancel is a callable method: 64 | pendingLogin.cancelRequest().catch(() => {}) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/core/login/keys.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { fixWalletInfo, mergeKeyInfos } from '../../../src/core/login/keys' 5 | 6 | const ID_1 = 'PPptx6SBfwGXM+FZURMvYnsOfHpIKZBbqXTCbYmFd44=' 7 | const ID_2 = 'y14MYFMP6vnip2hUBP7aqB6Ut0d4UNqHV9a/2vgE9eQ=' 8 | 9 | describe('mergeKeyInfos', function () { 10 | it('merge separate keys', function () { 11 | const key1 = { id: ID_1, type: 'foo', keys: { a: 1 } } 12 | const key2 = { id: ID_2, type: 'bar', keys: { a: 2 } } 13 | const out = mergeKeyInfos([key1, key2]) 14 | 15 | expect(out.length).equals(2) 16 | expect(out[0]).deep.equals(key1) 17 | expect(out[1]).deep.equals(key2) 18 | }) 19 | 20 | it('merge overlapping keys', function () { 21 | const key1 = { id: ID_1, type: 'foo', keys: { a: 1 } } 22 | const key2 = { id: ID_1, type: 'foo', keys: { b: 2 } } 23 | const key3 = { id: ID_1, type: 'foo', keys: { a: 1, b: 2 } } 24 | const out = mergeKeyInfos([key1, key2]) 25 | 26 | expect(out.length).equals(1) 27 | expect(out[0]).deep.equals(key3) 28 | expect(key1.keys).deep.equals({ a: 1 }) 29 | expect(key2.keys).deep.equals({ b: 2 }) 30 | }) 31 | 32 | it('merge conflicting types', function () { 33 | expect(() => 34 | mergeKeyInfos([ 35 | { id: ID_1, type: 'foo', keys: { a: 1 } }, 36 | { id: ID_1, type: 'bar', keys: { b: 2 } } 37 | ]) 38 | ).throws('Key integrity violation') 39 | }) 40 | 41 | it('merge conflicting keys', function () { 42 | expect(() => 43 | mergeKeyInfos([ 44 | { id: ID_1, type: 'foo', keys: { a: 1 } }, 45 | { id: ID_1, type: 'foo', keys: { a: 2 } } 46 | ]) 47 | ).throws('Key integrity violation') 48 | }) 49 | }) 50 | 51 | describe('fixWalletInfo', function () { 52 | it('handles legacy keys', function () { 53 | expect( 54 | fixWalletInfo({ 55 | id: 'id', 56 | keys: { bitcoinKey: 'bitcoinKey' }, 57 | type: 'wallet:bitcoin' 58 | }) 59 | ).deep.equals({ 60 | id: 'id', 61 | keys: { bitcoinKey: 'bitcoinKey', format: 'bip32' }, 62 | type: 'wallet:bitcoin' 63 | }) 64 | 65 | expect( 66 | fixWalletInfo({ 67 | id: 'id', 68 | keys: { bitcoinKey: 'bitcoinKey' }, 69 | type: 'wallet:bitcoin-bip44-testnet' 70 | }) 71 | ).deep.equals({ 72 | id: 'id', 73 | keys: { bitcoinKey: 'bitcoinKey', format: 'bip44', coinType: 1 }, 74 | type: 'wallet:bitcoin-testnet' 75 | }) 76 | }) 77 | 78 | it('leaves modern formats unchanged', function () { 79 | expect( 80 | fixWalletInfo({ 81 | id: 'id', 82 | keys: { bitcoinKey: 'bitcoinKey', format: 'bip32' }, 83 | type: 'wallet:bitcoin' 84 | }) 85 | ).deep.equals({ 86 | id: 'id', 87 | keys: { bitcoinKey: 'bitcoinKey', format: 'bip32' }, 88 | type: 'wallet:bitcoin' 89 | }) 90 | 91 | expect( 92 | fixWalletInfo({ 93 | id: 'id', 94 | keys: { 95 | bitcoinKey: 'bitcoinKey', 96 | format: 'bip44', 97 | coinType: 145 // Split from BCH 98 | }, 99 | type: 'wallet:bitcoin' 100 | }) 101 | ).deep.equals({ 102 | id: 'id', 103 | keys: { bitcoinKey: 'bitcoinKey', format: 'bip44', coinType: 145 }, 104 | type: 'wallet:bitcoin' 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/core/login/lobby.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import elliptic from 'elliptic' 3 | import { describe, it } from 'mocha' 4 | 5 | import { getInternalStuff } from '../../../src/core/context/internal-api' 6 | import { 7 | decryptLobbyReply, 8 | encryptLobbyReply 9 | } from '../../../src/core/login/lobby' 10 | import { makeFakeEdgeWorld, makeFakeIo } from '../../../src/index' 11 | 12 | const quiet = { onLog() {} } 13 | 14 | const EC = elliptic.ec 15 | const secp256k1 = new EC('secp256k1') 16 | const contextOptions = { apiKey: '', appId: '' } 17 | 18 | describe('edge login lobby', function () { 19 | it('round-trip data', function () { 20 | const io = makeFakeIo() 21 | const keypair = secp256k1.genKeyPair({ entropy: io.random(32) }) 22 | const pubkey = keypair.getPublic().encodeCompressed() 23 | const testReply = { testReply: 'This is a test' } 24 | 25 | const decrypted = decryptLobbyReply( 26 | keypair, 27 | encryptLobbyReply(io, Uint8Array.from(pubkey), testReply) 28 | ) 29 | expect(decrypted).deep.equals(testReply) 30 | }) 31 | 32 | it('lobby ping-pong', async function () { 33 | const world = await makeFakeEdgeWorld([], quiet) 34 | const context1 = await world.makeEdgeContext(contextOptions) 35 | const context2 = await world.makeEdgeContext(contextOptions) 36 | const i1 = getInternalStuff(context1) 37 | const i2 = getInternalStuff(context2) 38 | const testRequest = { loginRequest: { appId: 'some.test.app' } } 39 | const testReply = { testReply: 'This is a reply' } 40 | 41 | // Use 10 ms polling to really speed up the test: 42 | const lobby = await i1.makeLobby(testRequest, 10) 43 | const out: Promise = new Promise((resolve, reject) => { 44 | lobby.on('error', reject) 45 | lobby.watch('replies', (replies: unknown[]) => { 46 | if (replies.length === 0) return 47 | expect(replies[0]).deep.equals(testReply) 48 | resolve(undefined) 49 | }) 50 | }) 51 | 52 | try { 53 | const request = await i2.fetchLobbyRequest(lobby.lobbyId) 54 | expect(request).to.deep.include(testRequest) 55 | await i2.sendLobbyReply(lobby.lobbyId, request, testReply) 56 | await out 57 | } finally { 58 | lobby.close() 59 | } 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/core/login/splitting.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { fixWalletInfo } from '../../../src/core/login/keys' 5 | import { makeSplitWalletInfo } from '../../../src/core/login/splitting' 6 | 7 | describe('splitWalletInfo', function () { 8 | it('handles bitcoin to bitcoin cash', function () { 9 | expect( 10 | makeSplitWalletInfo( 11 | fixWalletInfo({ 12 | id: 'MPo9EF5krFQNYkxn2I0elOc0XPbs2x7GWjSxtb5c1WU=', 13 | type: 'wallet:bitcoin', 14 | keys: { 15 | bitcoinKey: '6p2cW62FeO1jQrbex/oTJ8R856bEnpZqPYxiRYV4fL8=', 16 | dataKey: 'zm6w4Q0mNpeZJXrhYRoXiiV2xgONxvmq2df42/2M40A=', 17 | syncKey: 'u8EIdKgxEG8j7buEt96Mq9usQ+k=' 18 | } 19 | }), 20 | 'wallet:bitcoincash' 21 | ) 22 | ).deep.equals({ 23 | id: 'SEsXNQxGL/D+8/vsBHJgwf7bAK6/OyR2BfescT7u/i4=', 24 | type: 'wallet:bitcoincash', 25 | keys: { 26 | bitcoincashKey: '6p2cW62FeO1jQrbex/oTJ8R856bEnpZqPYxiRYV4fL8=', 27 | dataKey: 'zm6w4Q0mNpeZJXrhYRoXiiV2xgONxvmq2df42/2M40A=', 28 | syncKey: 'w3AiUfoTk8vQfAwPayHy/sJDH7E=', 29 | format: 'bip32' 30 | } 31 | }) 32 | }) 33 | 34 | it('handles bitcoin cash to bitcoin', function () { 35 | expect( 36 | makeSplitWalletInfo( 37 | { 38 | id: 'MPo9EF5krFQNYkxn2I0elOc0XPbs2x7GWjSxtb5c1WU=', 39 | type: 'wallet:bitcoincash', 40 | keys: { 41 | bitcoincashKey: '6p2cW62FeO1jQrbex/oTJ8R856bEnpZqPYxiRYV4fL8=', 42 | dataKey: 'zm6w4Q0mNpeZJXrhYRoXiiV2xgONxvmq2df42/2M40A=', 43 | syncKey: 'u8EIdKgxEG8j7buEt96Mq9usQ+k=', 44 | format: 'bip44', 45 | coinType: 145 46 | } 47 | }, 48 | 'wallet:bitcoin' 49 | ) 50 | ).deep.equals({ 51 | id: 'SEsXNQxGL/D+8/vsBHJgwf7bAK6/OyR2BfescT7u/i4=', 52 | type: 'wallet:bitcoin', 53 | keys: { 54 | bitcoinKey: '6p2cW62FeO1jQrbex/oTJ8R856bEnpZqPYxiRYV4fL8=', 55 | dataKey: 'zm6w4Q0mNpeZJXrhYRoXiiV2xgONxvmq2df42/2M40A=', 56 | syncKey: 'w3AiUfoTk8vQfAwPayHy/sJDH7E=', 57 | format: 'bip44', 58 | coinType: 145 59 | } 60 | }) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /test/core/plugins/plugins.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { makeFakeEdgeWorld } from '../../../src/index' 5 | import { expectRejection } from '../../expect-rejection' 6 | import { fakeUser } from '../../fake/fake-user' 7 | 8 | const contextOptions = { apiKey: '', appId: '' } 9 | const quiet = { onLog() {} } 10 | 11 | describe('plugins system', function () { 12 | it('adds plugins', async function () { 13 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 14 | const context = await world.makeEdgeContext({ 15 | ...contextOptions, 16 | plugins: { 17 | 'missing-plugin': false, 18 | fakecoin: true, 19 | fakeswap: { apiKey: '' } 20 | } 21 | }) 22 | const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) 23 | 24 | expect(Object.keys(account.currencyConfig)).deep.equals(['fakecoin']) 25 | expect(Object.keys(account.swapConfig)).deep.equals(['fakeswap']) 26 | }) 27 | 28 | it('cannot log in with broken plugins', async function () { 29 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 30 | const context = await world.makeEdgeContext({ 31 | ...contextOptions, 32 | plugins: { 33 | 'broken-plugin': true, 34 | 'missing-plugin': true, 35 | fakeswap: false 36 | } 37 | }) 38 | await expectRejection( 39 | context.loginWithPIN(fakeUser.username, fakeUser.pin), 40 | 'Error: The following plugins are missing or failed to load: broken-plugin, missing-plugin' 41 | ) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/core/scrypt/snrp.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { calcSnrpForTarget } from '../../../src/core/scrypt/scrypt-pixie' 5 | 6 | describe('SNRP calculation', function () { 7 | const salt = new Uint8Array(32) 8 | 9 | it('basic functionality', function () { 10 | // Typical desktop with JS + V8: 11 | expect(calcSnrpForTarget(salt, 32, 2000)).deep.equals({ 12 | salt_hex: salt, 13 | n: 131072, 14 | r: 8, 15 | p: 14 16 | }) 17 | 18 | // Insane speeds: 19 | expect(calcSnrpForTarget(salt, 1, 2000)).deep.equals({ 20 | salt_hex: salt, 21 | n: 131072, 22 | r: 8, 23 | p: 64 24 | }) 25 | 26 | // Infinity: 27 | expect(calcSnrpForTarget(salt, 0, 2000)).deep.equals({ 28 | salt_hex: salt, 29 | n: 131072, 30 | r: 8, 31 | p: 64 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/core/storage/repo.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import { base64 } from 'rfc4648' 4 | 5 | import { 6 | EdgeInternalStuff, 7 | getInternalStuff 8 | } from '../../../src/core/context/internal-api' 9 | import { makeRepoPaths } from '../../../src/core/storage/repo' 10 | import { makeFakeEdgeWorld, makeFakeIo } from '../../../src/index' 11 | import { fakeUser } from '../../fake/fake-user' 12 | 13 | const contextOptions = { apiKey: '', appId: '' } 14 | const dataKey = base64.parse(fakeUser.loginKey) 15 | const syncKey = base64.parse(fakeUser.syncKey) 16 | const quiet = { onLog() {} } 17 | 18 | describe('repo', function () { 19 | it('read file', async function () { 20 | const io = makeFakeIo() 21 | const { disklet } = makeRepoPaths(io, { syncKey, dataKey }) 22 | const payload = '{"message":"Hello"}' 23 | const box = `{ 24 | "encryptionType": 0, 25 | "iv_hex": "82454458a5eaa6bc7dc4b4081b9f36d1", 26 | "data_base64": "lykLWi2MUBbcrdbbo2cZ9Q97aVohe6LZUihp7xfr1neAMj8mr0l9MP1ElteAzG4GG1FmjSsptajr6I2sNc5Kmw==" 27 | }` 28 | 29 | await io.disklet.setText( 30 | 'repos/GkVrxd1EmZpU6SkEwfo3911t1WjwBDW3tdrKd7QUDvvN/changes/a/b.json', 31 | box 32 | ) 33 | expect(await disklet.getText('a/b.json')).equals(payload) 34 | }) 35 | 36 | it('data round-trip', async function () { 37 | const io = makeFakeIo() 38 | const { disklet } = makeRepoPaths(io, { syncKey, dataKey }) 39 | const payload = 'Test data' 40 | 41 | await disklet.setText('b.txt', payload) 42 | expect(await disklet.getText('b.txt')).equals(payload) 43 | }) 44 | 45 | it('repo-to-repo sync', async function () { 46 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 47 | const context1 = await world.makeEdgeContext(contextOptions) 48 | const context2 = await world.makeEdgeContext(contextOptions) 49 | const i1 = getInternalStuff(context1) 50 | const i2 = getInternalStuff(context2) 51 | const disklet1 = await i1.getRepoDisklet(syncKey, dataKey) 52 | const disklet2 = await i2.getRepoDisklet(syncKey, dataKey) 53 | 54 | const payload = 'Test data' 55 | await disklet1.setText('a/b.json', payload) 56 | await i1.syncRepo(syncKey) 57 | await i2.syncRepo(syncKey) 58 | expect(await disklet2.getText('a/b.json')).equals(payload) 59 | }) 60 | 61 | it('large repo-to-repo sync', async function () { 62 | const world = await makeFakeEdgeWorld([fakeUser], quiet) 63 | const context1 = await world.makeEdgeContext(contextOptions) 64 | const context2 = await world.makeEdgeContext(contextOptions) 65 | const i1 = getInternalStuff(context1) 66 | const i2 = getInternalStuff(context2) 67 | const disklet1 = await i1.getRepoDisklet(syncKey, dataKey) 68 | const disklet2 = await i2.getRepoDisklet(syncKey, dataKey) 69 | 70 | const files = Array.from({ length: 333 }, (_, i) => `a/${i}.txt`) 71 | await Promise.all( 72 | files.map(async file => await disklet1.setText(file, `${file} content`)) 73 | ) 74 | 75 | async function fullSync(internal: EdgeInternalStuff): Promise { 76 | let i: number = 0 77 | while (true) { 78 | const response = await internal.syncRepo(syncKey) 79 | const j = Object.keys(response.changes).length 80 | if (j === i) break 81 | i = j 82 | } 83 | } 84 | 85 | await fullSync(i1) 86 | await fullSync(i2) 87 | 88 | await Promise.all( 89 | files.map(async file => { 90 | expect(await disklet2.getText(file)).equals(`${file} content`) 91 | }) 92 | ) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /test/core/swap.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { sortQuotes } from '../../src/core/swap/swap-api' 5 | import { EdgeSwapInfo, EdgeSwapQuote, EdgeSwapRequest } from '../../src/index' 6 | 7 | const typeHack: any = {} 8 | 9 | const request: EdgeSwapRequest = typeHack 10 | const dummySwapInfo: EdgeSwapInfo = { 11 | pluginId: '', 12 | displayName: '', 13 | supportEmail: '' 14 | } 15 | 16 | const quotes: EdgeSwapQuote[] = [ 17 | { 18 | request, 19 | swapInfo: dummySwapInfo, 20 | fromNativeAmount: '51734472727286000', 21 | toNativeAmount: '347987', 22 | networkFee: { 23 | currencyCode: 'ETH', 24 | nativeAmount: '3492187272714000' 25 | } as any, 26 | pluginId: 'changenow', 27 | expirationDate: new Date('2022-01-21T04:35:22.033Z'), 28 | isEstimate: false, 29 | approve: async () => typeHack, 30 | close: async () => undefined 31 | }, 32 | { 33 | request, 34 | swapInfo: dummySwapInfo, 35 | isEstimate: false, 36 | fromNativeAmount: '51734472727286000', 37 | toNativeAmount: '321913.5410141837507493644', 38 | networkFee: { 39 | currencyCode: 'ETH', 40 | nativeAmount: '3492187272714000' 41 | } as any, 42 | expirationDate: new Date('2022-01-21T04:35:18.000Z'), 43 | pluginId: 'switchain', 44 | approve: async () => typeHack, 45 | close: async () => undefined 46 | }, 47 | { 48 | request, 49 | swapInfo: dummySwapInfo, 50 | fromNativeAmount: '51734472727286000', 51 | toNativeAmount: '327854', 52 | networkFee: { 53 | currencyCode: 'ETH', 54 | nativeAmount: '3492187272714000' 55 | } as any, 56 | pluginId: 'godex', 57 | expirationDate: new Date('2022-01-21T04:53:22.097Z'), 58 | isEstimate: false, 59 | approve: async () => typeHack, 60 | close: async () => undefined 61 | }, 62 | { 63 | request, 64 | swapInfo: { ...dummySwapInfo, isDex: true }, 65 | fromNativeAmount: '51734472727286000', 66 | toNativeAmount: '326854', 67 | networkFee: { 68 | currencyCode: 'ETH', 69 | nativeAmount: '3492187272714000' 70 | }, 71 | pluginId: 'thorchain', 72 | expirationDate: new Date('2022-01-21T04:53:22.097Z'), 73 | isEstimate: false, 74 | approve: async () => typeHack, 75 | close: async () => undefined 76 | } 77 | ] 78 | 79 | describe('swap', function () { 80 | const getIds = (quotes: EdgeSwapQuote[]): string => 81 | quotes.map(quote => quote.pluginId).join(', ') 82 | 83 | it('picks the best quote', function () { 84 | const sorted = sortQuotes(quotes, {}) 85 | expect(getIds(sorted)).equals('changenow, godex, thorchain, switchain') 86 | }) 87 | 88 | it('picks the preferred swap provider', function () { 89 | const sorted = sortQuotes(quotes, { preferPluginId: 'switchain' }) 90 | expect(getIds(sorted)).equals('switchain, changenow, godex, thorchain') 91 | }) 92 | 93 | it('picks the preferred swap type DEX', function () { 94 | const sorted = sortQuotes(quotes, { preferType: 'DEX' }) 95 | expect(getIds(sorted)).equals('thorchain, changenow, godex, switchain') 96 | }) 97 | 98 | it('picks the preferred swap type CEX', function () { 99 | const sorted = sortQuotes(quotes, { preferType: 'CEX' }) 100 | expect(getIds(sorted)).equals('changenow, godex, switchain, thorchain') 101 | }) 102 | 103 | it('picks the swap provider with an active promo code', function () { 104 | const sorted = sortQuotes(quotes, { 105 | promoCodes: { 106 | switchain: 'deal10' 107 | } 108 | }) 109 | expect(getIds(sorted)).equals('switchain, changenow, godex, thorchain') 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /test/expect-rejection.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | /** 4 | * Verifies that a promise rejects with a particular error. 5 | */ 6 | export function expectRejection( 7 | promise: Promise, 8 | message?: string 9 | ): Promise { 10 | return promise.then( 11 | ok => { 12 | throw new Error('Expecting this promise to reject') 13 | }, 14 | error => { 15 | if (message != null) expect(String(error)).equals(message) 16 | } 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /test/fake/fake-broken-engine.ts: -------------------------------------------------------------------------------- 1 | import { EdgeCurrencyPlugin } from '../../src' 2 | 3 | export const brokenEnginePlugin: EdgeCurrencyPlugin = { 4 | currencyInfo: { 5 | addressExplorer: '', 6 | currencyCode: 'BORK', 7 | defaultSettings: {}, 8 | denominations: [], 9 | displayName: 'Broken Engine', 10 | chainDisplayName: 'Broken Chain', 11 | assetDisplayName: 'Broke Coin', 12 | metaTokens: [], 13 | pluginId: 'broken-engine', 14 | transactionExplorer: '', 15 | walletType: 'wallet:broken' 16 | }, 17 | 18 | async makeCurrencyEngine() { 19 | throw new SyntaxError("I can't do this") 20 | }, 21 | 22 | async makeCurrencyTools() { 23 | return { 24 | createPrivateKey() { 25 | return Promise.resolve({}) 26 | }, 27 | derivePublicKey() { 28 | return Promise.resolve({}) 29 | }, 30 | getSplittableTypes() { 31 | return Promise.resolve([]) 32 | }, 33 | parseUri() { 34 | return Promise.resolve({}) 35 | }, 36 | encodeUri() { 37 | return Promise.resolve('') 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fake/fake-plugins.ts: -------------------------------------------------------------------------------- 1 | import { brokenEnginePlugin } from './fake-broken-engine' 2 | import { fakeCurrencyPlugin } from './fake-currency-plugin' 3 | import { fakeSwapPlugin } from './fake-swap-plugin' 4 | 5 | export const allPlugins = { 6 | 'broken-plugin': () => { 7 | throw new Error('Expect to fail') 8 | }, 9 | 'broken-engine': brokenEnginePlugin, 10 | fakecoin: fakeCurrencyPlugin, 11 | fakeswap: fakeSwapPlugin 12 | } 13 | -------------------------------------------------------------------------------- /test/fake/fake-swap-plugin.ts: -------------------------------------------------------------------------------- 1 | import { asObject, asOptional, asString } from 'cleaners' 2 | 3 | import { 4 | EdgeSwapInfo, 5 | EdgeSwapPlugin, 6 | EdgeSwapPluginStatus, 7 | EdgeSwapQuote, 8 | EdgeSwapRequest, 9 | SwapCurrencyError, 10 | SwapPermissionError 11 | } from '../../src/index' 12 | 13 | const swapInfo: EdgeSwapInfo = { 14 | displayName: 'Fake Swapper', 15 | pluginId: 'fakeswap', 16 | 17 | supportEmail: 'support@fakeswap' 18 | } 19 | 20 | const asFakeSwapSettings = asObject({ 21 | kycToken: asOptional(asString) 22 | }) 23 | 24 | export const fakeSwapPlugin: EdgeSwapPlugin = { 25 | swapInfo, 26 | 27 | checkSettings(userSettings: object): EdgeSwapPluginStatus { 28 | const cleanSettings = asFakeSwapSettings(userSettings) 29 | return { needsActivation: cleanSettings.kycToken == null } 30 | }, 31 | 32 | fetchSwapQuote( 33 | request: EdgeSwapRequest, 34 | userSettings: object = {} 35 | ): Promise { 36 | const cleanSettings = asFakeSwapSettings(userSettings) 37 | 38 | // We need KYC: 39 | if (cleanSettings.kycToken == null) { 40 | throw new SwapPermissionError(swapInfo, 'noVerification') 41 | } 42 | 43 | // We don't actually support any currencies: 44 | throw new SwapCurrencyError(swapInfo, request) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/setup.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach } from 'mocha' 2 | 3 | import { 4 | addEdgeCorePlugins, 5 | closeEdge, 6 | lockEdgeCorePlugins 7 | } from '../src/index' 8 | import { allPlugins } from './fake/fake-plugins' 9 | 10 | afterEach(function () { 11 | closeEdge() 12 | }) 13 | 14 | addEdgeCorePlugins(allPlugins) 15 | lockEdgeCorePlugins() 16 | -------------------------------------------------------------------------------- /test/util/asMap.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { asDate, uncleaner } from 'cleaners' 3 | import { describe, it } from 'mocha' 4 | 5 | import { asMap } from '../../src/util/asMap' 6 | 7 | describe('asMap', function () { 8 | const asDates = asMap(asDate) 9 | 10 | it('cleans JSON data', function () { 11 | const clean = asDates({ 12 | btc: '2009-01-03', 13 | usa: '1776-07-04' 14 | }) 15 | 16 | expect(Array.from(clean.entries())).deep.equals([ 17 | ['btc', new Date('2009-01-03')], 18 | ['usa', new Date('1776-07-04')] 19 | ]) 20 | }) 21 | 22 | it('restores JSON data', function () { 23 | const wasDates = uncleaner(asDates) 24 | 25 | const clean = new Map([ 26 | ['btc', new Date('2009-01-03')], 27 | ['usa', new Date('1776-07-04')] 28 | ]) 29 | 30 | expect(wasDates(clean)).deep.equals({ 31 | btc: '2009-01-03T00:00:00.000Z', 32 | usa: '1776-07-04T00:00:00.000Z' 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/util/compare.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { compare } from '../../src/util/compare' 5 | 6 | describe('compare', function () { 7 | it('compare', function () { 8 | expect(compare(1, 2)).equals(false) 9 | expect(compare(1, '1')).equals(false) 10 | expect(compare(1, null)).equals(false) 11 | expect(compare({ a: 1 }, {})).equals(false) 12 | expect(compare({}, { a: 1 })).equals(false) 13 | expect(compare({ a: 1 }, { a: 2 })).equals(false) 14 | expect(compare([1, 2], [1])).equals(false) 15 | expect(compare([1, 2], [1, '2'])).equals(false) 16 | 17 | expect(compare(1, 1)).equals(true) 18 | expect(compare({ a: 1 }, { a: 1 })).equals(true) 19 | expect(compare([1, 2], [1, 2])).equals(true) 20 | expect(compare(new Date(20), new Date(20))).equals(true) 21 | expect(compare(new Map(), new Map())).equals(true) 22 | expect( 23 | compare( 24 | new Map([ 25 | [null, 2], 26 | ['a', 2] 27 | ]), 28 | new Map([ 29 | ['a', 2], 30 | [null, 2] 31 | ]) 32 | ) 33 | ).equals(true) 34 | expect( 35 | compare( 36 | new Map([ 37 | [null, 1], 38 | ['a', 2] 39 | ]), 40 | new Map([ 41 | ['a', 2], 42 | [null, 2] 43 | ]) 44 | ) 45 | ).equals(false) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/util/crypto/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import { base16 } from 'rfc4648' 4 | 5 | import { makeFakeIo } from '../../../src/index' 6 | import { asEdgeBox } from '../../../src/types/server-cleaners' 7 | import { decrypt, decryptText, encrypt } from '../../../src/util/crypto/crypto' 8 | import { utf8 } from '../../../src/util/encoding' 9 | 10 | describe('encryption', function () { 11 | it('decrypts existing data', function () { 12 | const key = base16.parse( 13 | '002688cc350a5333a87fa622eacec626c3d1c0ebf9f3793de3885fa254d7e393' 14 | ) 15 | const box = asEdgeBox({ 16 | data_base64: 17 | 'X08Snnou2PrMW21ZNyJo5C8StDjTNgMtuEoAJL5bJ6LDPdZGQLhjaUMetOknaPYnmfBCHNQ3ApqmE922Hkp30vdxzXBloopfPLJKdYwQxURYNbiL4TvNakP7i0bnTlIsR7bj1q/65ZyJOW1HyOKV/tmXCf56Fhe3Hcmb/ebsBF72FZr3jX5pkSBO+angK15IlCIiem1kPi6QmzyFtMB11i0GTjSS67tLrWkGIqAmik+bGqy7WtQgfMRxQNNOxePPSHHp09431Ogrc9egY3txnBN2FKnfEM/0Wa/zLWKCVQXCGhmrTx1tmf4HouNDOnnCgkRWJYs8FJdrDP8NZy4Fkzs7FoH7RIaUiOvosNKMil1CBknKremP6ohK7SMLGoOHpv+bCgTXcAeB3P4Slx3iy+RywTSLb3yh+HDo6bwt+vhujP0RkUamI5523bwz3/7vLO8BzyF6WX0By2s4gvMdFQ==', 18 | encryptionType: 0, 19 | iv_hex: '96a4cd52670c13df9712fdc1b564d44b' 20 | }) 21 | 22 | expect(decrypt(box, key)).deep.equals(utf8.parse('payload')) 23 | }) 24 | 25 | it('round-trips data', function () { 26 | const io = makeFakeIo() 27 | const key = base16.parse( 28 | '002688cc350a5333a87fa622eacec626c3d1c0ebf9f3793de3885fa254d7e393' 29 | ) 30 | const data = utf8.parse('payload\0') 31 | const box = encrypt(io, data, key) 32 | expect(decryptText(box, key)).equals('payload') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/util/crypto/hashes.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import { base16 } from 'rfc4648' 4 | 5 | import { hmacSha256, sha256 } from '../../../src/util/crypto/hashes' 6 | import { utf8 } from '../../../src/util/encoding' 7 | 8 | describe('hashes', function () { 9 | it('hmac-sha256', function () { 10 | const data = utf8.parse('The quick brown fox jumps over the lazy dog') 11 | const key = utf8.parse('key') 12 | 13 | expect(base16.stringify(hmacSha256(data, key))).equals( 14 | 'F7BC83F430538424B13298E6AA6FB143EF4D59A14946175997479DBC2D1A3CD8' 15 | ) 16 | }) 17 | 18 | it('sha256', function () { 19 | const data = utf8.parse('This is a test') 20 | 21 | expect(base16.stringify(sha256(data))).equals( 22 | 'C7BE1ED902FB8DD4D48997C6452F5D7E509FBCDBE2808B16BCF4EDCE4C07D14E' 23 | ) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/util/crypto/hotp.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import { base16, base32 } from 'rfc4648' 4 | 5 | import { hotp, numberToBe64 } from '../../../src/util/crypto/hotp' 6 | import { utf8 } from '../../../src/util/encoding' 7 | 8 | describe('hotp', function () { 9 | it('converts numbers to bytes', function () { 10 | const cases: Array<[number, string]> = [ 11 | // Powers of 2, plus 1: 12 | [1, '0000000000000001'], 13 | [257, '0000000000000101'], 14 | [65537, '0000000000010001'], 15 | [16777217, '0000000001000001'], 16 | [4294967297, '0000000100000001'], 17 | [1099511627777, '0000010000000001'], 18 | [281474976710657, '0001000000000001'], 19 | [72057594037927937, '0100000000000000'], // eslint-disable-line no-loss-of-precision 20 | // The edge of the representable integers: 21 | [9007199254740991, '001FFFFFFFFFFFFF'], 22 | [9007199254740992, '0020000000000000'], 23 | [9007199254740993, '0020000000000000'], // eslint-disable-line no-loss-of-precision 24 | [9007199254740994, '0020000000000002'], 25 | // Fractions: 26 | [0.75, '0000000000000000'], 27 | [1.75, '0000000000000001'], 28 | // Negative numbers: 29 | [-1, 'FFFFFFFFFFFFFFFF'], 30 | [-256, 'FFFFFFFFFFFFFF00'], 31 | [-257, 'FFFFFFFFFFFFFEFF'], 32 | [-4294967296, 'FFFFFFFF00000000'], 33 | [-4294967297, 'FFFFFFFEFFFFFFFF'], 34 | [-9007199254740992, 'FFE0000000000000'] 35 | ] 36 | for (const [number, hex] of cases) { 37 | expect(numberToBe64(number)).deep.equals(base16.parse(hex)) 38 | } 39 | }) 40 | 41 | it('Handles official rfc4226 test vectors', function () { 42 | const key = utf8.parse('12345678901234567890') 43 | const cases = [ 44 | '755224', 45 | '287082', 46 | '359152', 47 | '969429', 48 | '338314', 49 | '254676', 50 | '287922', 51 | '162583', 52 | '399871', 53 | '520489' 54 | ] 55 | 56 | for (let i = 0; i < cases.length; ++i) { 57 | expect(hotp(key, i, 6)).equals(cases[i]) 58 | } 59 | }) 60 | 61 | it('Handles leading zeros in output', function () { 62 | const key = base32.parse('AAAAAAAA') 63 | expect(hotp(key, 2, 6)).equals('073348') 64 | expect(hotp(key, 9, 6)).equals('003773') 65 | expect(hotp(key, 41952, 6)).equals('048409') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/util/crypto/scrypt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import { base16, base64 } from 'rfc4648' 4 | 5 | import { scrypt } from '../../../src/util/crypto/scrypt' 6 | import { utf8 } from '../../../src/util/encoding' 7 | 8 | describe('scrypt', function () { 9 | it('match a known userId', async function () { 10 | const password = utf8.parse('william test') 11 | const salt = base16.parse( 12 | 'b5865ffb9fa7b3bfe4b2384d47ce831ee22a4a9d5c34c7ef7d21467cc758f81b' 13 | ) 14 | 15 | const userId = await scrypt(password, salt, 16384, 1, 1, 32) 16 | expect(base64.stringify(userId)).equals( 17 | 'TGnly9w3Fch7tyJVO+0MWLpvlbMGgWODf/tFlNkV6js=' 18 | ) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/util/encoding.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import { base16 } from 'rfc4648' 4 | 5 | import { utf8 } from '../../src/util/encoding' 6 | 7 | describe('encoding', function () { 8 | it('utf8', function () { 9 | const tests = [ 10 | { string: 'ascii', data: '6173636969' }, 11 | { string: 'テスト', data: 'E38386E382B9E38388' }, 12 | { string: '😀', data: 'F09F9880' } 13 | ] 14 | 15 | for (const { string, data } of tests) { 16 | const bytes = base16.parse(data) 17 | 18 | // utf8.parse: 19 | expect(utf8.parse(string)).deep.equals(bytes) 20 | 21 | // utf8.stringify: 22 | expect(utf8.stringify(bytes)).equals(string) 23 | } 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/util/promise.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { fuzzyTimeout } from '../../src/util/promise' 5 | import { snooze } from '../../src/util/snooze' 6 | import { expectRejection } from '../expect-rejection' 7 | 8 | describe('promise', function () { 9 | it('fuzzyTimeout resolves', async function () { 10 | expect(await fuzzyTimeout([snooze(1), snooze(20)], 10)).deep.equals({ 11 | results: [1], 12 | errors: [] 13 | }) 14 | expect(await fuzzyTimeout([snooze(1), snooze(2)], 10)).deep.equals({ 15 | results: [1, 2], 16 | errors: [] 17 | }) 18 | expect(await fuzzyTimeout([snooze(20), snooze(30)], 10)).deep.equals({ 19 | results: [20], 20 | errors: [] 21 | }) 22 | 23 | const error = new Error('Expected') 24 | const data = [snooze(1), Promise.reject(error), snooze(1000)] 25 | expect(await fuzzyTimeout(data, 10)).deep.equals({ 26 | results: [1], 27 | errors: [error] 28 | }) 29 | 30 | await expectRejection( 31 | fuzzyTimeout([Promise.reject(error), Promise.reject(error)], 10), 32 | 'Error: Expected,Error: Expected' 33 | ) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/util/validateServer.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | 4 | import { validateServer } from '../../src/util/validateServer' 5 | 6 | describe('validateServer', function () { 7 | it('accepts valid login server overrides', function () { 8 | for (const server of [ 9 | 'https://login.edge.app/app', 10 | 'https://login2.edge.app/app', 11 | 'https://login-test.edge.app', 12 | 'https://login-test.edge.app/app', 13 | 'https://edgetest.app', 14 | 'https://login.edgetest.app', 15 | 'http://localhost', 16 | 'http://localhost/app', 17 | 'https://localhost/app', 18 | 'http://localhost:8080/app' 19 | ]) { 20 | validateServer(server) 21 | } 22 | }) 23 | 24 | it('rejects invalid login server overrides', function () { 25 | for (const server of [ 26 | 'https://login.hacker.com/app', 27 | 'https://login.not-edge.app/app', 28 | 'https://edge.app:fun@hacker.com/app', 29 | 'https://login.edgetes.app/app', 30 | 'http://login.edge.app/app', 31 | 'ftp://login.edge.app' 32 | ]) { 33 | expect(() => validateServer(server)).to.throw( 34 | 'Only *.edge.app or localhost are valid login domain names' 35 | ) 36 | } 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | 5 | "esModuleInterop": true, 6 | "jsx": "react", 7 | "module": "es2015", 8 | "moduleResolution": "node", 9 | "target": "es2015", 10 | 11 | "strict": true 12 | }, 13 | "exclude": ["lib"] 14 | } 15 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export * from './src/types/types' 2 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('child_process') 2 | const path = require('path') 3 | const TerserPlugin = require('terser-webpack-plugin') 4 | const webpack = require('webpack') 5 | 6 | // Use "yarn prepare.dev" to make a debug-friendly static build: 7 | const debug = 8 | process.env.WEBPACK_SERVE || process.env.EDGE_MODE === 'development' 9 | 10 | // Try exposing our socket to adb (errors are fine): 11 | if (process.env.WEBPACK_SERVE) { 12 | console.log('adb reverse tcp:8080 tcp:8080') 13 | exec('adb reverse tcp:8080 tcp:8080', () => {}) 14 | } 15 | 16 | const bundlePath = path.resolve( 17 | __dirname, 18 | 'android/src/main/assets/edge-core-js' 19 | ) 20 | 21 | const babelOptions = { 22 | presets: debug 23 | ? ['@babel/preset-typescript', '@babel/preset-react'] 24 | : ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'], 25 | plugins: [ 26 | ['@babel/plugin-transform-for-of', { assumeArray: true }], 27 | '@babel/plugin-transform-runtime', 28 | 'babel-plugin-transform-fake-error-class' 29 | ], 30 | cacheDirectory: true 31 | } 32 | 33 | module.exports = { 34 | devtool: debug ? 'source-map' : undefined, 35 | devServer: { 36 | allowedHosts: 'all', 37 | hot: false, 38 | static: bundlePath 39 | }, 40 | entry: './src/io/react-native/react-native-worker.ts', 41 | mode: debug ? 'development' : 'production', 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.ts$/, 46 | exclude: /node_modules/, 47 | use: debug 48 | ? { 49 | loader: '@sucrase/webpack-loader', 50 | options: { transforms: ['typescript'] } 51 | } 52 | : { 53 | loader: 'babel-loader', 54 | options: babelOptions 55 | } 56 | }, 57 | { 58 | include: path.resolve(__dirname, 'node_modules/buffer/index.js'), 59 | use: { 60 | loader: 'babel-loader', 61 | options: { presets: ['@babel/preset-env'] } 62 | } 63 | } 64 | ] 65 | }, 66 | optimization: { 67 | minimizer: [new TerserPlugin({ terserOptions: { safari10: true } })] 68 | }, 69 | output: { 70 | filename: 'edge-core.js', 71 | path: bundlePath 72 | }, 73 | plugins: [ 74 | new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), 75 | new webpack.ProvidePlugin({ process: ['process'] }) 76 | ], 77 | performance: { hints: false }, 78 | resolve: { 79 | extensions: ['.tsx', '.ts', '.js'], 80 | fallback: { 81 | assert: require.resolve('assert/'), 82 | buffer: require.resolve('buffer/'), 83 | stream: require.resolve('stream-browserify') 84 | } 85 | }, 86 | target: ['web', 'es5'] 87 | } 88 | --------------------------------------------------------------------------------