diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0ca8b451..7d617a0ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,254 +1,258 @@ Contributing to Bitcoin ABC =========================== The Bitcoin ABC project welcomes contributors! This guide is intended to help developers contribute effectively to Bitcoin ABC. Communicating with Developers ----------------------------- To get in contact with ABC developers, we monitor a telegram supergroup. The intent of this group is specifically to facilitate development of Bitcoin-ABC, and to welcome people who wish to participate. [Join the ABC Development telegram group](https://t.me/joinchat/HCYr50mxRWjA2uLqii-psw) Acceptable use of this supergroup includes the following: * Introducing yourself to other ABC developers. * Getting help with your development environment. * Discussing how to complete a patch. It is not for: * Market discussion * Non-constructive criticism Bitcoin ABC Development Philosophy ---------------------------------- Bitcoin ABC aims for fast iteration and continuous integration. This means that there should be quick turnaround for patches to be proposed, reviewed, and committed. Changes should not sit in a queue for long. Here are some tips to help keep the development working as intended. These are guidelines for the normal and expected development process. Developers can use their judgement to deviate from these guidelines when they have a good reason to do so. - Keep each change small and self-contained. - Reach out for a 1-on-1 review so things move quickly. - Land the Diff quickly after it is accepted. - Don't amend changes after the Diff accepted, new Diff for another fix. - Review Diffs from other developers as quickly as possible. - Large changes should be broken into logical chunks that are easy to review, and keep the code in a functional state. - Do not mix moving stuff around with changing stuff. Do changes with renames on their own. - Sometimes you want to replace one subsystem by another implementation, in which case it is not possible to do things incrementally. In such cases, you keep both implementations in the codebase for a while, as described [here](https://www.gamasutra.com/view/news/128325/Opinion_Parallel_Implementations.php). - There are no "development" branches, all Diffs apply to the master branch, and should always improve it (no regressions). - Don't break the build, it is important to keep master green as much as possible. If a Diff is landed, and breaks the build, fix it quickly. If it cannot be fixed quickly, it should be reverted, and re-applied later when it no longer breaks the build. - As soon as you see a bug, you fix it. Do not continue on. Fixing the bug becomes the top priority, more important than completing other tasks. - Automate as much as possible, and spend time on things only humans can do. Here are some handy links for development practices aligned with Bitcoin ABC: - [Developer Notes](doc/developer-notes.md) - [Statement of Bitcoin ABC Values and Visions](https://www.yours.org/content/bitcoin-abc---our-values-and-vision-a282afaade7c) - [How to Do Code Reviews Like a Human - Part 1](https://mtlynch.io/human-code-reviews-1/) - [How to Do Code Reviews Like a Human - Part 2](https://mtlynch.io/human-code-reviews-2/) - [Large Diffs Are Hurting Your Ability To Ship](https://medium.com/@kurtisnusbaum/large-diffs-are-hurting-your-ability-to-ship-e0b2b41e8acf) - [Stacked Diffs: Keeping Phabricator Diffs Small](https://medium.com/@kurtisnusbaum/stacked-diffs-keeping-phabricator-diffs-small-d9964f4dcfa6) - [Parallel Implementations](https://www.gamasutra.com/view/news/128325/Opinion_Parallel_Implementations.php) - [The Pragmatic Programmer: From Journeyman to Master](https://www.amazon.com/Pragmatic-Programmer-Journeyman-Master/dp/020161622X) - [Advantages of monolithic version control](https://danluu.com/monorepo/) - [The importance of fixing bugs immediately](https://youtu.be/E2MIpi8pIvY?t=16m0s) - [Slow Deployment Causes Meetings](https://www.facebook.com/notes/kent-beck/slow-deployment-causes-meetings/1055427371156793/) - [Good Work, Great Work, and Right Work](https://forum.dlang.org/post/q7u6g1$94p$1@digitalmars.com) - [Accelerate: The Science of Lean Software and DevOps](https://www.amazon.com/Accelerate-Software-Performing-Technology-Organizations/dp/1942788339) - [Facebook Engineering Process with Kent Beck](https://softwareengineeringdaily.com/2019/08/28/facebook-engineering-process-with-kent-beck/) Getting set up with the Bitcoin ABC Repository ---------------------------------------------- 1. Create an account at [reviews.bitcoinabc.org](https://reviews.bitcoinabc.org/) 2. Install Git and Arcanist on your machine Git documentation can be found at [git-scm.com](https://git-scm.com/). For Arcanist documentation, you can read [Arcanist Quick Start](https://secure.phabricator.com/book/phabricator/article/arcanist_quick_start/) and the [Arcanist User Guide](https://secure.phabricator.com/book/phabricator/article/arcanist/). To install these packages on Debian or Ubuntu, type: `sudo apt-get install git arcanist` 3. If you do not already have an SSH key set up, follow these steps: Type: `ssh-keygen -t rsa -b 4096 -C "your_email@example.com"` Enter a file in which to save the key (/home/*username*/.ssh/id_rsa): [Press enter] 4. Upload your SSH public key to - Go to: `https://reviews.bitcoinabc.org/settings/user/*username*/page/ssh/` - Under "SSH Key Actions", Select "Upload Public Key" Paste contents from: `/home/*username*/.ssh/id_rsa.pub` 5. Clone the repository and install Arcanist certificate: ``` git clone ssh://vcs@reviews.bitcoinabc.org:2221/source/bitcoin-abc.git cd bitcoin-abc arc install-certificate ``` Note: Arcanist tooling will tend to fail if your remote origin is set to something other than the above. A common mistake is to clone from Github and then forget to update your remotes. Follow instructions provided by `arc install-certificate` to provide your API token. Contributing to the node software --------------------------------- During submission of patches, arcanist will automatically run `arc lint` to enforce Bitcoin ABC code formatting standards, and often suggests changes. If code formatting tools do not install automatically on your system, you will have to install the following: On Ubuntu (>= 18.04+updates): ``` sudo apt-get install clang-format-8 clang-tidy-8 clang-tools-8 cppcheck python3-autopep8 flake8 php-codesniffer yamllint ``` On Debian (>= 10), the clang-8 family of tools is available from the `buster-backports` repository: ``` echo "deb http://deb.debian.org/debian buster-backports main" | sudo tee -a /etc/apt/sources.list sudo apt-get update sudo apt-get install cppcheck python3-autopep8 flake8 php-codesniffer sudo apt-get -t buster-backports install clang-format-8 clang-tidy-8 clang-tools-8 ``` If not available in the distribution, `clang-format-8` and `clang-tidy` can be installed from or . For example, for macOS: ``` curl http://releases.llvm.org/8.0.0/clang+llvm-8.0.0-x86_64-apple-darwin.tar.xz | tar -xJv ln -s $PWD/clang+llvm-8.0.0-x86_64-apple-darwin/bin/clang-format /usr/local/bin/clang-format ln -s $PWD/clang+llvm-8.0.0-x86_64-apple-darwin/bin/clang-tidy /usr/local/bin/clang-tidy ``` If you are modifying a shell script, you will need to install the `shellcheck` linter. A recent version is required and may not be packaged for your distribution. Standalone binaries are available for download on [the project's github release page](https://github.com/koalaman/shellcheck/releases). **Note**: In order for arcanist to detect the `shellcheck` executable, you need to make it available in your `PATH`; if another version is already installed, make sure the recent one is found first. Arcanist will tell you what version is expected and what is found when running `arc lint` against a shell script. If you are running Debian 10, it is also available in the backports repository: ``` sudo apt-get -t buster-backports install shellcheck ``` Contributing to the web projects -------------------------------- -If you intend to contribute to web projects, you will need `nodejs`. -Follow the [installation instructions](https://github.com/nvm-sh/nvm#installing-and-updating) -to install node with node version manager. Then: +To contribute to web projects, you will need `nodejs` > 15 and `npm` > 6.14.8. +Follow these [installation instructions](https://github.com/nvm-sh/nvm#installing-and-updating) +to install `nodejs` with node version manager. + +Then: ``` cd bitcoin-abc -sudo npm install -g prettier +[sudo] nvm install 15 +[sudo] npm install -g npm@latest +[sudo] npm install -g prettier ``` Working with The Bitcoin ABC Repository --------------------------------------- A typical workflow would be: - Create a topic branch in Git for your changes git checkout -b 'my-topic-branch' - Make your changes, and commit them git commit -a -m 'my-commit' - Create a differential with Arcanist arc diff You should add suggested reviewers and a test plan to the commit message. Note that Arcanist is set up to look only at the most-recent commit message, So all you changes for this Diff should be in one Git commit. - For large changes, break them into several Diffs, as described in this [guide](https://medium.com/@kurtisnusbaum/stacked-diffs-keeping-phabricator-diffs-small-d9964f4dcfa6). You can also include "Depends on Dxxx" in the Arcanist message to indicate dependence on other Diffs. - Log into Phabricator to see review and feedback. - Make changes as suggested by the reviewers. You can simply edit the files with my-topic-branch checked out, and then type `arc diff`. Arcanist will give you the option to add uncommited changes. Or, alternatively, you can commit the changes using `git commit -a --am` to add them to the last commit, or squash multiple commits by typing `git rebase -i master`. If you squash, make sure the commit message has the information needed for arcanist (such as the Diff number, reviewers, etc.). - Update your Diff by typing `arc diff` again. - When reviewers approve your Diff, it should be listed as "ready to Land" in Phabricator. When you want to commit your diff to the repository, check out type my-topic-branch in git, then type `arc land`. You have now successfully committed a change to the Bitcoin ABC repository. - When reviewing a Diff, apply the changeset on your local by using `arc patch D{NNNN}` - You will likely be re-writing git histories multiple times, which causes timestamp changes that require re-building a significant number of files. It's highly recommended to install `ccache` (re-run cmake if you install it later), as this will help cut your re-build times from several minutes to under a minute, in many cases. What to work on --------------- If you are looking for a useful task to contribute to the project, a good place to start is the list of tasks at . You could also try [backporting](doc/backporting.md) some code from Bitcoin Core. Copyright --------- By contributing to this repository, you agree to license your work under the MIT license unless specified otherwise in `contrib/debian/copyright` or at the top of the file itself. Any work contributed where you are not the original author must contain its license header with the original author(s) and source. Disclosure Policy ----------------- See [DISCLOSURE_POLICY](DISCLOSURE_POLICY.md). diff --git a/web/cashtab/.env b/web/cashtab/.env new file mode 100644 index 000000000..94b45c2e8 --- /dev/null +++ b/web/cashtab/.env @@ -0,0 +1,3 @@ +REACT_APP_NETWORK=mainnet +REACT_APP_API=https://rest.kingbch.com/v3/ +REACT_APP_API_TEST=https://free-test.fullstack.cash/v3/ \ No newline at end of file diff --git a/web/cashtab/.gitignore b/web/cashtab/.gitignore new file mode 100644 index 000000000..8bbeb8790 --- /dev/null +++ b/web/cashtab/.gitignore @@ -0,0 +1,30 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist +/extension + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Chrome Extension testing +*.crx +*.zip +*.pem \ No newline at end of file diff --git a/web/cashtab/.nvmrc b/web/cashtab/.nvmrc new file mode 100644 index 000000000..3f10ffe7a --- /dev/null +++ b/web/cashtab/.nvmrc @@ -0,0 +1 @@ +15 \ No newline at end of file diff --git a/web/cashtab/.prettierignore b/web/cashtab/.prettierignore new file mode 100644 index 000000000..8bbeb8790 --- /dev/null +++ b/web/cashtab/.prettierignore @@ -0,0 +1,30 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist +/extension + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Chrome Extension testing +*.crx +*.zip +*.pem \ No newline at end of file diff --git a/web/cashtab/README.md b/web/cashtab/README.md new file mode 100644 index 000000000..3923157a7 --- /dev/null +++ b/web/cashtab/README.md @@ -0,0 +1,51 @@ +# CashTab + +## Bitcoin Cash Web Wallet + +### Features + +- Send & Receive BCH +- Import existing wallets + +## Development + +Cashtab relies on some modules that retain legacy dependencies. NPM version 7 or later no longer supports automatic resolution of these peer dependencies. To successfully install modules such as `qrcode.react`, with NPM > 7, run `npm install` with the flag `--legacy-peer-deps` + +``` +npm install --legacy-peer-deps +npm start +``` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +## Testing + +### 'npm test' + +### 'npm run test:coverage' + +## Production + +In the project directory, run: + +### `npm run build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +## CashTab Roadmap + +The following features are under active development: + +- Transaction history +- Simple Ledger Postage Protocol Support +- Cashtab as browser extension diff --git a/web/cashtab/config/env.js b/web/cashtab/config/env.js new file mode 100644 index 000000000..445a2b1a4 --- /dev/null +++ b/web/cashtab/config/env.js @@ -0,0 +1,93 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.', + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }), + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in Webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + }, + ); + // Stringify all values so we can feed into Webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/web/cashtab/config/jest/cssTransform.js b/web/cashtab/config/jest/cssTransform.js new file mode 100644 index 000000000..e70083e7b --- /dev/null +++ b/web/cashtab/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/web/cashtab/config/jest/fileTransform.js b/web/cashtab/config/jest/fileTransform.js new file mode 100644 index 000000000..ee5e6ef90 --- /dev/null +++ b/web/cashtab/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); +const camelcase = require('camelcase'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFileName = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFileName}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/web/cashtab/config/modules.js b/web/cashtab/config/modules.js new file mode 100644 index 000000000..d98eb1734 --- /dev/null +++ b/web/cashtab/config/modules.js @@ -0,0 +1,88 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); +const chalk = require('react-dev-utils/chalk'); +const resolve = require('resolve'); + +/** + * Get the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ''; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return [paths.appSrc]; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.', + ), + ); +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.', + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync('typescript', { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/web/cashtab/config/paths.js b/web/cashtab/config/paths.js new file mode 100644 index 000000000..f32378040 --- /dev/null +++ b/web/cashtab/config/paths.js @@ -0,0 +1,86 @@ +const path = require('path'); +const fs = require('fs'); +const url = require('url'); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = relativePath => path.resolve(appDirectory, relativePath); + +const envPublicUrl = process.env.PUBLIC_URL; + +function ensureSlash(inputPath, needsSlash) { + const hasSlash = inputPath.endsWith('/'); + if (hasSlash && !needsSlash) { + return inputPath.substr(0, inputPath.length - 1); + } else if (!hasSlash && needsSlash) { + return `${inputPath}/`; + } else { + return inputPath; + } +} + +const getPublicUrl = appPackageJson => + envPublicUrl || require(appPackageJson).homepage; + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// Webpack needs to know it to put the right + CashTab + + + +
+ + diff --git a/web/cashtab/public/manifest.json b/web/cashtab/public/manifest.json new file mode 100644 index 000000000..720f411a0 --- /dev/null +++ b/web/cashtab/public/manifest.json @@ -0,0 +1,35 @@ +{ + "short_name": "CashTab", + "name": "CashTab", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "bch48.png", + "type": "image/png", + "sizes": "48x48" + }, + { + "src": "bch128.png", + "type": "image/png", + "sizes": "128x128" + }, + { + "src": "bch192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "bch512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/web/cashtab/public/robots.txt b/web/cashtab/public/robots.txt new file mode 100644 index 000000000..01b0f9a10 --- /dev/null +++ b/web/cashtab/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/web/cashtab/screenshots/ss01.png b/web/cashtab/screenshots/ss01.png new file mode 100644 index 000000000..ce3d229c3 Binary files /dev/null and b/web/cashtab/screenshots/ss01.png differ diff --git a/web/cashtab/screenshots/ss02.png b/web/cashtab/screenshots/ss02.png new file mode 100644 index 000000000..f50657b88 Binary files /dev/null and b/web/cashtab/screenshots/ss02.png differ diff --git a/web/cashtab/screenshots/ss03.png b/web/cashtab/screenshots/ss03.png new file mode 100644 index 000000000..0807f6a94 Binary files /dev/null and b/web/cashtab/screenshots/ss03.png differ diff --git a/web/cashtab/screenshots/ss04.jpg b/web/cashtab/screenshots/ss04.jpg new file mode 100644 index 000000000..9c571d593 Binary files /dev/null and b/web/cashtab/screenshots/ss04.jpg differ diff --git a/web/cashtab/scripts/build.js b/web/cashtab/scripts/build.js new file mode 100644 index 000000000..d641193e5 --- /dev/null +++ b/web/cashtab/scripts/build.js @@ -0,0 +1,188 @@ +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'production'; +process.env.NODE_ENV = 'production'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const path = require('path'); +const chalk = require('react-dev-utils/chalk'); +const fs = require('fs-extra'); +const webpack = require('webpack'); +const configFactory = require('../config/webpack.config'); +const paths = require('../config/paths'); +const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); +const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); +const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); +const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); +const printBuildError = require('react-dev-utils/printBuildError'); + +const measureFileSizesBeforeBuild = + FileSizeReporter.measureFileSizesBeforeBuild; +const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; +const useYarn = fs.existsSync(paths.yarnLockFile); + +// These sizes are pretty large. We'll warn for bundles exceeding them. +const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; +const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; + +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// Generate configuration +const config = configFactory('production'); + +// We require that you explicitly set browsers and do not fall back to +// browserslist defaults. +const { checkBrowsers } = require('react-dev-utils/browsersHelper'); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // First, read the current file sizes in build directory. + // This lets us display how much they changed later. + return measureFileSizesBeforeBuild(paths.appBuild); + }) + .then(previousFileSizes => { + // Remove all content but keep the directory so that + // if you're in it, you don't end up in Trash + fs.emptyDirSync(paths.appBuild); + // Merge with the public folder + copyPublicFolder(); + // Start the webpack build + return build(previousFileSizes); + }) + .then( + ({ stats, previousFileSizes, warnings }) => { + if (warnings.length) { + console.log(chalk.yellow('Compiled with warnings.\n')); + console.log(warnings.join('\n\n')); + console.log( + '\nSearch for the ' + + chalk.underline(chalk.yellow('keywords')) + + ' to learn more about each warning.', + ); + console.log( + 'To ignore, add ' + + chalk.cyan('// eslint-disable-next-line') + + ' to the line before.\n', + ); + } else { + console.log(chalk.green('Compiled successfully.\n')); + } + + console.log('File sizes after gzip:\n'); + printFileSizesAfterBuild( + stats, + previousFileSizes, + paths.appBuild, + WARN_AFTER_BUNDLE_GZIP_SIZE, + WARN_AFTER_CHUNK_GZIP_SIZE, + ); + console.log(); + + const appPackage = require(paths.appPackageJson); + const publicUrl = paths.publicUrl; + const publicPath = config.output.publicPath; + const buildFolder = path.relative(process.cwd(), paths.appBuild); + printHostingInstructions( + appPackage, + publicUrl, + publicPath, + buildFolder, + useYarn, + ); + }, + err => { + console.log(chalk.red('Failed to compile.\n')); + printBuildError(err); + process.exit(1); + }, + ) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); + +// Create the production build and print the deployment instructions. +function build(previousFileSizes) { + // We used to support resolving modules according to `NODE_PATH`. + // This now has been deprecated in favor of jsconfig/tsconfig.json + // This lets you use absolute paths in imports inside large monorepos: + if (process.env.NODE_PATH) { + console.log( + chalk.yellow( + 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.', + ), + ); + console.log(); + } + + console.log('Creating an optimized production build...'); + + const compiler = webpack(config); + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + let messages; + if (err) { + if (!err.message) { + return reject(err); + } + messages = formatWebpackMessages({ + errors: [err.message], + warnings: [], + }); + } else { + messages = formatWebpackMessages( + stats.toJson({ all: false, warnings: true, errors: true }), + ); + } + if (messages.errors.length) { + // Only keep the first error. Others are often indicative + // of the same problem, but confuse the reader with noise. + if (messages.errors.length > 1) { + messages.errors.length = 1; + } + return reject(new Error(messages.errors.join('\n\n'))); + } + if ( + process.env.CI && + (typeof process.env.CI !== 'string' || + process.env.CI.toLowerCase() !== 'false') && + messages.warnings.length + ) { + console.log( + chalk.yellow( + '\nTreating warnings as errors because process.env.CI = true.\n' + + 'Most CI servers set it automatically.\n', + ), + ); + return reject(new Error(messages.warnings.join('\n\n'))); + } + + return resolve({ + stats, + previousFileSizes, + warnings: messages.warnings, + }); + }); + }); +} + +function copyPublicFolder() { + fs.copySync(paths.appPublic, paths.appBuild, { + dereference: true, + filter: file => file !== paths.appHtml, + }); +} diff --git a/web/cashtab/scripts/start.js b/web/cashtab/scripts/start.js new file mode 100644 index 000000000..db3a4ba4f --- /dev/null +++ b/web/cashtab/scripts/start.js @@ -0,0 +1,146 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'development'; +process.env.NODE_ENV = 'development'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const fs = require('fs'); +const chalk = require('react-dev-utils/chalk'); +const webpack = require('webpack'); +const WebpackDevServer = require('webpack-dev-server'); +const clearConsole = require('react-dev-utils/clearConsole'); +const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); +const { + choosePort, + createCompiler, + prepareProxy, + prepareUrls, +} = require('react-dev-utils/WebpackDevServerUtils'); +const openBrowser = require('react-dev-utils/openBrowser'); +const paths = require('../config/paths'); +const configFactory = require('../config/webpack.config'); +const createDevServerConfig = require('../config/webpackDevServer.config'); + +const useYarn = fs.existsSync(paths.yarnLockFile); +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// Tools like Cloud9 rely on this. +const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; +const HOST = process.env.HOST || '0.0.0.0'; + +if (process.env.HOST) { + console.log( + chalk.cyan( + `Attempting to bind to HOST environment variable: ${chalk.yellow( + chalk.bold(process.env.HOST), + )}`, + ), + ); + console.log( + `If this was unintentional, check that you haven't mistakenly set it in your shell.`, + ); + console.log( + `Learn more here: ${chalk.yellow( + 'https://bit.ly/CRA-advanced-config', + )}`, + ); + console.log(); +} + +// We require that you explicitly set browsers and do not fall back to +// browserslist defaults. +const { checkBrowsers } = require('react-dev-utils/browsersHelper'); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // We attempt to use the default port but if it is busy, we offer the user to + // run on a different port. `choosePort()` Promise resolves to the next free port. + return choosePort(HOST, DEFAULT_PORT); + }) + .then(port => { + if (port == null) { + // We have not found a port. + return; + } + const config = configFactory('development'); + const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; + const appName = require(paths.appPackageJson).name; + const useTypeScript = fs.existsSync(paths.appTsConfig); + const urls = prepareUrls(protocol, HOST, port); + const devSocket = { + warnings: warnings => + devServer.sockWrite(devServer.sockets, 'warnings', warnings), + errors: errors => + devServer.sockWrite(devServer.sockets, 'errors', errors), + }; + // Create a webpack compiler that is configured with custom messages. + const compiler = createCompiler({ + appName, + config, + devSocket, + urls, + useYarn, + useTypeScript, + webpack, + }); + // Load proxy config + const proxySetting = require(paths.appPackageJson).proxy; + const proxyConfig = prepareProxy(proxySetting, paths.appPublic); + // Serve webpack assets generated by the compiler over a web server. + const serverConfig = createDevServerConfig( + proxyConfig, + urls.lanUrlForConfig, + ); + const devServer = new WebpackDevServer(compiler, serverConfig); + // Launch WebpackDevServer. + devServer.listen(port, HOST, err => { + if (err) { + return console.log(err); + } + if (isInteractive) { + clearConsole(); + } + + // We used to support resolving modules according to `NODE_PATH`. + // This now has been deprecated in favor of jsconfig/tsconfig.json + // This lets you use absolute paths in imports inside large monorepos: + if (process.env.NODE_PATH) { + console.log( + chalk.yellow( + 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.', + ), + ); + console.log(); + } + + console.log(chalk.cyan('Starting the development server...\n')); + openBrowser(urls.localUrlForBrowser); + }); + + ['SIGINT', 'SIGTERM'].forEach(function (sig) { + process.on(sig, function () { + devServer.close(); + process.exit(); + }); + }); + }) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); diff --git a/web/cashtab/scripts/test.js b/web/cashtab/scripts/test.js new file mode 100644 index 000000000..55827a634 --- /dev/null +++ b/web/cashtab/scripts/test.js @@ -0,0 +1,51 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'test'; +process.env.NODE_ENV = 'test'; +process.env.PUBLIC_URL = ''; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const jest = require('jest'); +const execSync = require('child_process').execSync; +let argv = process.argv.slice(2); + +function isInGitRepository() { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +function isInMercurialRepository() { + try { + execSync('hg --cwd . root', { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } +} + +// Watch unless on CI or explicitly running all tests +if ( + !process.env.CI && + argv.indexOf('--watchAll') === -1 && + argv.indexOf('--watchAll=false') === -1 +) { + // https://github.com/facebook/create-react-app/issues/5210 + const hasSourceControl = isInGitRepository() || isInMercurialRepository(); + argv.push(hasSourceControl ? '--watch' : '--watchAll'); +} + +jest.run(argv); diff --git a/web/cashtab/src/assets/12-bitcoin-cash-square-crop.svg b/web/cashtab/src/assets/12-bitcoin-cash-square-crop.svg new file mode 100644 index 000000000..c3f7066cc --- /dev/null +++ b/web/cashtab/src/assets/12-bitcoin-cash-square-crop.svg @@ -0,0 +1 @@ +12-bitcoin-cash-square-crop \ No newline at end of file diff --git a/web/cashtab/src/assets/4-bitcoin-cash-logo-flag.svg b/web/cashtab/src/assets/4-bitcoin-cash-logo-flag.svg new file mode 100644 index 000000000..503ba9afa --- /dev/null +++ b/web/cashtab/src/assets/4-bitcoin-cash-logo-flag.svg @@ -0,0 +1 @@ +4-bitcoin-cash-logo-flag \ No newline at end of file diff --git a/web/cashtab/src/assets/bitcoinabclogo.png b/web/cashtab/src/assets/bitcoinabclogo.png new file mode 100644 index 000000000..4874d3105 Binary files /dev/null and b/web/cashtab/src/assets/bitcoinabclogo.png differ diff --git a/web/cashtab/src/assets/cashtab.png b/web/cashtab/src/assets/cashtab.png new file mode 100644 index 000000000..ffaf4f7d8 Binary files /dev/null and b/web/cashtab/src/assets/cashtab.png differ diff --git a/web/cashtab/src/assets/edit.svg b/web/cashtab/src/assets/edit.svg new file mode 100644 index 000000000..976ca75e7 --- /dev/null +++ b/web/cashtab/src/assets/edit.svg @@ -0,0 +1 @@ +Create \ No newline at end of file diff --git a/web/cashtab/src/assets/fonts/RobotoMono-Regular.ttf b/web/cashtab/src/assets/fonts/RobotoMono-Regular.ttf new file mode 100755 index 000000000..7c4ce36a4 Binary files /dev/null and b/web/cashtab/src/assets/fonts/RobotoMono-Regular.ttf differ diff --git a/web/cashtab/src/assets/hammer-solid.svg b/web/cashtab/src/assets/hammer-solid.svg new file mode 100644 index 000000000..7f2c70953 --- /dev/null +++ b/web/cashtab/src/assets/hammer-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/cashtab/src/assets/ios-paperplane.svg b/web/cashtab/src/assets/ios-paperplane.svg new file mode 100644 index 000000000..04109dd0b --- /dev/null +++ b/web/cashtab/src/assets/ios-paperplane.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + diff --git a/web/cashtab/src/assets/simple-ledger-protocol-logo.png b/web/cashtab/src/assets/simple-ledger-protocol-logo.png new file mode 100644 index 000000000..3e39423a8 Binary files /dev/null and b/web/cashtab/src/assets/simple-ledger-protocol-logo.png differ diff --git a/web/cashtab/src/assets/slp-logo-2.png b/web/cashtab/src/assets/slp-logo-2.png new file mode 100644 index 000000000..cf202cd51 Binary files /dev/null and b/web/cashtab/src/assets/slp-logo-2.png differ diff --git a/web/cashtab/src/assets/slp-logo.png b/web/cashtab/src/assets/slp-logo.png new file mode 100644 index 000000000..76b7f13b7 Binary files /dev/null and b/web/cashtab/src/assets/slp-logo.png differ diff --git a/web/cashtab/src/assets/slp-oval.png b/web/cashtab/src/assets/slp-oval.png new file mode 100644 index 000000000..bafa98f1e Binary files /dev/null and b/web/cashtab/src/assets/slp-oval.png differ diff --git a/web/cashtab/src/assets/slp-qrcode.png b/web/cashtab/src/assets/slp-qrcode.png new file mode 100644 index 000000000..171cdaf55 Binary files /dev/null and b/web/cashtab/src/assets/slp-qrcode.png differ diff --git a/web/cashtab/src/assets/slp-sticker.jpg b/web/cashtab/src/assets/slp-sticker.jpg new file mode 100644 index 000000000..c77c21fc9 Binary files /dev/null and b/web/cashtab/src/assets/slp-sticker.jpg differ diff --git a/web/cashtab/src/assets/trashcan.svg b/web/cashtab/src/assets/trashcan.svg new file mode 100644 index 000000000..71a445858 --- /dev/null +++ b/web/cashtab/src/assets/trashcan.svg @@ -0,0 +1 @@ +Trash \ No newline at end of file diff --git a/web/cashtab/src/components/App.css b/web/cashtab/src/components/App.css new file mode 100644 index 000000000..be002ff67 --- /dev/null +++ b/web/cashtab/src/components/App.css @@ -0,0 +1,409 @@ +@import '~antd/dist/antd.less'; +@import '~@fortawesome/fontawesome-free/css/all.css'; +@import url('https://fonts.googleapis.com/css?family=Khula&display=swap&.css'); + +@font-face { + font-family: 'Roboto Mono'; + src: local('Roboto Mono'), + url(../assets/fonts/RobotoMono-Regular.ttf) format('truetype'); + font-weight: normal; +} + +aside::-webkit-scrollbar { + width: 0.3em; +} +aside::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px #13171f; +} +aside::-webkit-scrollbar-thumb { + background-color: darkgrey; + outline: 1px solid slategrey; +} + +/* Hide up and down arros on input type="number" */ +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Hide up and down arros on input type="number" */ +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} + +html, +body { + max-width: 100%; + overflow-x: hidden; +} + +.ant-modal-wrap.ant-modal-centered::-webkit-scrollbar { + display: none; +} + +.App { + text-align: center; + font-family: 'Gilroy', sans-serif; + background-color: #fbfbfd; +} +.App-logo { + width: 100%; + display: block; +} + +.logo img { + width: 100%; + min-width: 193px; + display: block; + padding-left: 24px; + padding-right: 34px; + padding-top: 24px; + max-width: 200px; +} +.ant-list-item-meta .ant-list-item-meta-content { + display: flex; +} + +#react-qrcode-logo { + border-radius: 8px; +} +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #f59332; +} +.ant-menu-item-group-title { + padding-left: 30px; + font-size: 20px !important; + font-weight: 500 !important; +} + +.ant-menu-item > span { + font-size: 14px !important; + font-weight: 500 !important; +} + +.ant-card-actions > li > span:hover, +.ant-btn:hover, +.ant-btn:focus { + color: #f59332; + transition: color 0.3s; + background-color: white; +} + +.ant-card-actions > li { + color: #3e3f42; +} +.anticon { + color: #3e3f42; +} +.ant-list-item-meta-description, +.ant-list-item-meta-title { + color: #3e3f42; +} + +.ant-list-item-meta-description > :first-child { + right: 20px !important; + position: absolute; +} + +.ant-modal-body .ant-list-item-meta { + height: 85px; + width: 85px; + padding-left: 10px; + padding-top: 10px; + padding-bottom: 20px; + overflow: visible !important; +} + +/* .ant-radio-group-solid .ant-radio-button-wrapper { + margin-top: 0px; +} + +.ant-radio-group-solid .ant-radio-button-wrapper-checked { + border: none !important; + box-shadow: none !important; +} */ +.identicon { + border-radius: 50%; + width: 200px; + height: 200px; + margin-top: -75px; + margin-left: -75px; + margin-bottom: 20px; + box-shadow: 1px 1px 2px 1px #444; +} +.ant-list-item-meta { + width: 40px; + height: 40px; +} + +/* .ant-radio-group-solid .ant-radio-button-wrapper-checked { + background: #ff8d00 !important; +} + +.ant-radio-group.ant-radio-group-solid.ant-radio-group-small { + font-size: 14px !important; + font-weight: 600 !important; + vertical-align: middle; + border-radius: 100px; + overflow: auto; + background: rgba(255, 255, 255, 0.5) !important; + margin-top: 14px; + margin-bottom: 10px; + cursor: pointer; +} */ + +input.ant-input, +.ant-select-selection { + background-color: #fff !important; + box-shadow: none !important; + border: 1px solid #eaedf3 !important; + border-radius: 4px; + font-weight: bold; + color: rgb(62, 63, 66); + opacity: 1; + padding: 11px 5px; + height: 50px; +} + +.ant-select-selection:hover { + border: 1px solid #eaedf3; +} + +.ant-select-selection-selected-value { + color: rgb(62, 63, 66); +} + +.ant-select-dropdown-menu-item { + color: #444; + background-color: #fff; +} + +.ant-select-dropdown-menu-item-active, +.ant-select-dropdown-menu-item:hover { + color: #fff; + background-color: #ff8d00 !important; +} + +.ant-checkbox-inner { + border: 1px solid #eaedf3 !important; + background: white; +} + +.ant-checkbox-inner::after { + border-color: white !important; +} + +.ant-card-bordered { + border: 1px solid rgb(234, 237, 243); + border-radius: 8px; +} + +.ant-card-actions { + border-top: 1px solid rgb(234, 237, 243); + border-bottom: 1px solid rgb(234, 237, 243); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + box-shadow: 0px 5px 8px rgba(0, 0, 0, 0.35); +} + +.ant-input-group-addon { + background-color: #f4f4f4 !important; + border: 1px solid rgb(234, 237, 243); + color: #3e3f42 !important; + + * { + color: #3e3f42 !important; + } +} + +.ant-menu-item.ant-menu-item-selected > * { + color: #fff !important; +} + +.ant-menu-item.ant-menu-item-selected { + border: 0; + overflow: hidden; + text-align: left; + padding-left: 28px; + background-color: rgba(255, 255, 255, 0.2) !important; +} + +.ant-btn { + border-radius: 8px; + background-color: #fff; + color: rgb(62, 63, 66); + font-weight: bold; +} + +.ant-card-actions > li:not(:last-child) { + border-right: 0; +} +.ant-list-item-meta-avatar > img { + margin-left: -12px; + transform: translate(0, -6px); +} + +.ant-list-item-meta-avatar > svg { + margin-right: -70px; +} + +/* Removing these for ABC SLP warning +.ant-alert-warning { + background-color: #20242d; + border: 1px solid #17171f; + border-radius: 0; +} + +.ant-alert-message { + color: #fff; +} +*/ + +.ant-layout-sider-dark { + background: linear-gradient(0deg, #040c3c, #212c6e); +} + +.ant-menu-dark { + background: none; +} + +.ant-layout-sider-zero-width-trigger.ant-layout-sider-zero-width-trigger-left + .anticon.anticon-bars { + color: #fff; + transform: scale(1.3); +} + +.ant-layout-sider-zero-width-trigger.ant-layout-sider-zero-width-trigger-left { + background: #3e3f42; + border-radius: 0 8px 8px 0; +} + +.ant-btn-group .ant-btn-primary:first-child:not(:last-child) { + border-right-color: transparent !important; +} + +.ant-btn-group .ant-btn-primary:last-child:not(:first-child), +.ant-btn-group .ant-btn-primary + .ant-btn-primary { + border-left-color: #20242d !important; +} + +.audit { + a, + a:active { + color: #46464a; + } + + a:hover { + color: #111117; + } +} + +.dividends { + a, + a:active { + color: #111117; + } + + a:hover { + color: #46464a; + } +} + +.ant-popover-inner-content { + color: white; +} + +.ant-modal-body .ant-card { + max-width: 100%; +} + +.ant-upload.ant-upload-drag { + border: 1px solid #eaedf3; + border-radius: 8px; + background: #d3d3d3; +} + +.ant-upload-list-item:hover .ant-upload-list-item-info { + background-color: #ffffff; +} + +/* .ant-radio-button-wrapper { + border: none; +} + +.ant-radio-button-wrapper-checked { + border-radius: none !important; +} */ + +/* .ant-radio-button-wrapper:first-child, .ant-radio-button-wrapper:last-child { + border-radius: 0 0 0 0; +} */ + +.ant-radio-group { + width: 100%; + margin-top: 10px; +} + +.ant-radio-button-wrapper { + background: rgba(255, 255, 255, 0.2); + width: 104px; + border: none; + text-align: center; + color: #fff; +} + +.ant-radio-button-wrapper:hover { + color: #fff; + background: rgba(255, 255, 255, 0.3); +} + +.ant-radio-group-small .ant-radio-button-wrapper { + height: 35px; + line-height: 35px; +} + +.ant-radio-button-wrapper-checked { + background: #ff8d00 !important; + border: none !important; +} + +.ant-radio-button-wrapper:first-child { + border-radius: 100px 0 0 100px; +} + +.ant-radio-button-wrapper:last-child { + border-radius: 0 100px 100px 0; +} + +::selection { + background-color: #ff8d00; +} + +@media (max-width: 768px) { + .ant-notification { + width: 100%; + top: 20px !important; + max-width: unset; + margin-right: 0; + } +} + +@media (max-width: 350px) { + .ant-select-selection-selected-value { + font-size: 10px; + } +} diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js new file mode 100644 index 000000000..d49419530 --- /dev/null +++ b/web/cashtab/src/components/App.js @@ -0,0 +1,268 @@ +import React from 'react'; +import 'antd/dist/antd.less'; +import '../index.css'; +import styled from 'styled-components'; +import { Layout, Tabs, Icon } from 'antd'; +import Wallet from './Wallet/Wallet'; +import Send from './Send/Send'; +import SendToken from './Send/SendToken'; +import Configure from './Configure/Configure'; +import NotFound from './NotFound'; +import CashTab from '../assets/cashtab.png'; +import ABC from '../assets/bitcoinabclogo.png'; +import './App.css'; +import { WalletContext } from '../utils/context'; +import { + Route, + Redirect, + Switch, + useLocation, + useHistory, +} from 'react-router-dom'; + +const { Footer } = Layout; +const { TabPane } = Tabs; + +const StyledTabsMenu = styled.div` + .ant-layout-footer { + position: absolute; + bottom: 0; + width: 100%; + padding: 0; + background-color: #fff; + left: 0; + border-radius: 20px; + border-top: 1px solid #e2e2e2; + @media (max-width: 768px) { + position: fixed; + } + } + .ant-tabs-nav .ant-tabs-tab { + padding: 30px 0 20px 0; + } + .ant-tabs-bar.ant-tabs-bottom-bar { + margin-top: 0; + border-top: none; + } + .ant-tabs-tab { + span { + font-size: 12px; + display: grid; + font-weight: bold; + } + .anticon { + color: rgb(148, 148, 148); + font-size: 24px; + margin-left: 8px; + margin-bottom: 3px; + } + } + .ant-tabs-tab:hover { + color: #ff8d00 !important; + .anticon { + color: #ff8d00; + } + } + .ant-tabs-tab-active.ant-tabs-tab { + color: #ff8d00; + .anticon { + color: #ff8d00; + } + } + .ant-tabs-tab-active.ant-tabs-tab { + color: #ff8d00; + .anticon { + color: #ff8d00; + } + } + .ant-tabs-tab-active:active { + color: #ff8d00 !important; + } + .ant-tabs-ink-bar { + display: none !important; + } + .ant-tabs-nav { + margin: -3.5px 0 0 0; + } +`; + +export const WalletBody = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 100vh; + background: linear-gradient(270deg, #040c3c, #212c6e); +`; + +export const WalletCtn = styled.div` + position: relative; + width: 500px; + background-color: #fff; + min-height: 100vh; + padding-top: 30px; + padding: 10px 30px 100px 30px; + background: #fff; + -webkit-box-shadow: 0px 0px 24px 1px rgba(0, 0, 0, 1); + -moz-box-shadow: 0px 0px 24px 1px rgba(0, 0, 0, 1); + box-shadow: 0px 0px 24px 1px rgba(0, 0, 0, 1); + @media (max-width: 768px) { + width: 100%; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + } +`; + +export const HeaderCtn = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 20px 0 30px; + margin-bottom: 20px; + justify-content: space-between; + border-bottom: 1px solid #e2e2e2; + + a { + color: #848484; + + :hover { + color: #ff8d00; + } + } + + @media (max-width: 768px) { + a { + font-size: 12px; + } + padding: 10px 0 20px; + } +`; + +export const CashTabLogo = styled.img` + width: 120px; + @media (max-width: 768px) { + width: 110px; + } +`; +export const AbcLogo = styled.img` + width: 150px; + @media (max-width: 768px) { + width: 120px; + } +`; + +const App = () => { + const ContextValue = React.useContext(WalletContext); + const { wallet } = ContextValue; + const location = useLocation(); + const history = useHistory(); + const selectedKey = + location && location.pathname ? location.pathname.substr(1) : ''; + + return ( +
+ + + + + + + + + + + + + + + + ( + + )} + /> + + + + + + + + {wallet ? ( + +
+ + + history.push('/wallet') + } + > + + Wallet + + } + key="wallet" + /> + + history.push('/send') + } + > + + Send + + } + key="send" + disabled={!wallet} + /> + + history.push('/configure') + } + > + + Settings + + } + key="configure" + disabled={!wallet} + /> + +
+
+ ) : null} +
+
+
+ ); +}; + +export default App; diff --git a/web/cashtab/src/components/Common/CustomIcons.js b/web/cashtab/src/components/Common/CustomIcons.js new file mode 100644 index 000000000..d8175d685 --- /dev/null +++ b/web/cashtab/src/components/Common/CustomIcons.js @@ -0,0 +1,110 @@ +import * as React from 'react'; +import styled from 'styled-components'; +import { Icon, Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; + +export const CashLoadingIcon = ( + +); + +export const CashSpin = styled(Spin)` + svg { + width: 50px; + height: 50px; + fill: #ff8d00; + } +`; + +export const LoadingBlock = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + flex-direction: column; + svg { + width: 50px; + height: 50px; + fill: #ff8d00; + } +`; + +const hammer = () => ( + + + +); + +export const CashLoader = () => ( + + + +); + +const plane = () => ( + + + + + + + + + + + + + + + +); + +const fire = () => ( + +); + +export const HammerIcon = props => ; + +export const PlaneIcon = props => ; + +export const FireIcon = props => ; diff --git a/web/cashtab/src/components/Common/CustomSpinner.js b/web/cashtab/src/components/Common/CustomSpinner.js new file mode 100644 index 000000000..fee031cf2 --- /dev/null +++ b/web/cashtab/src/components/Common/CustomSpinner.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import styled from 'styled-components'; +import { Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; + +export const CashSpinIcon = ( + +); +export const CashSpin = styled(Spin)` + svg { + width: 50px; + height: 50px; + fill: #ff8d00; + } +`; diff --git a/web/cashtab/src/components/Common/EnhancedInputs.js b/web/cashtab/src/components/Common/EnhancedInputs.js new file mode 100644 index 000000000..d7adecbfa --- /dev/null +++ b/web/cashtab/src/components/Common/EnhancedInputs.js @@ -0,0 +1,168 @@ +import * as React from 'react'; +import { Form, Input, Icon, Select } from 'antd'; +import styled from 'styled-components'; +import { ScanQRCode } from './ScanQRCode'; +import useBCH from '../../hooks/useBCH'; +import { currency } from '../Common/Ticker.js'; + +export const InputAddonText = styled.span` + width: 100%; + height: 100%; + display: block; + + ${props => + props.disabled + ? ` + cursor: not-allowed; + ` + : `cursor: pointer;`} +`; + +export const InputNumberAddonText = styled.span` + background-color: #f4f4f4 !important; + border: 1px solid rgb(234, 237, 243); + color: #3e3f42 !important; + height: 50px; + line-height: 47px; + + * { + color: #3e3f42 !important; + } + ${props => + props.disabled + ? ` + cursor: not-allowed; + ` + : `cursor: pointer;`} +`; + +export const SendBchInput = ({ + onMax, + inputProps, + selectProps, + ...otherProps +}) => { + const { Option } = Select; + const currencies = [ + { + value: currency.ticker, + label: currency.ticker, + }, + { value: 'USD', label: 'USD' }, + ]; + const currencyOptions = currencies.map(currency => { + return ( + + ); + }); + + const CurrencySelect = ( + + ); + return ( + + + + ) : ( + + ) + } + {...inputProps} + /> + {CurrencySelect} + + max + + + + ); +}; + +export const FormItemWithMaxAddon = ({ onMax, inputProps, ...otherProps }) => { + return ( + + + } + addonAfter={ + + max + + } + {...inputProps} + /> + + ); +}; + +// loadWithCameraOpen prop: if true, load page with camera scanning open +export const FormItemWithQRCodeAddon = ({ + onScan, + loadWithCameraOpen, + inputProps, + ...otherProps +}) => { + return ( + + } + addonAfter={ + + } + {...inputProps} + /> + + ); +}; + +export const AddressValidators = () => { + const { BCH } = useBCH(); + + return { + safelyDetectAddressFormat: value => { + try { + return BCH.Address.detectAddressFormat(value); + } catch (error) { + return null; + } + }, + isSLPAddress: value => + AddressValidators.safelyDetectAddressFormat(value) === 'slpaddr', + isBCHAddress: value => + AddressValidators.safelyDetectAddressFormat(value) === 'cashaddr', + isLegacyAddress: value => + AddressValidators.safelyDetectAddressFormat(value) === 'legacy', + }(); +}; diff --git a/web/cashtab/src/components/Common/PrimaryButton.js b/web/cashtab/src/components/Common/PrimaryButton.js new file mode 100644 index 000000000..9819e0882 --- /dev/null +++ b/web/cashtab/src/components/Common/PrimaryButton.js @@ -0,0 +1,88 @@ +import styled from 'styled-components'; + +const PrimaryButton = styled.button` + border: none; + color: #fff; + background-image: linear-gradient(270deg, #ff8d00 0%, #bb5a00 100%); + transition: all 0.5s ease; + background-size: 200% auto; + font-size: 18px; + width: 100%; + padding: 20px 0; + border-radius: 4px; + margin-bottom: 20px; + cursor: pointer; + :hover { + background-position: right center; + -webkit-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + } + svg { + fill: #fff; + } + @media (max-width: 768px) { + font-size: 16px; + padding: 15px 0; + } +`; + +const SecondaryButton = styled.button` + border: none; + color: #444; + background: #e9eaed; + transition: all 0.5s ease; + font-size: 18px; + width: 100%; + padding: 15px 0; + border-radius: 4px; + cursor: pointer; + outline: none; + margin-bottom: 20px; + :hover { + -webkit-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + } + svg { + fill: #444; + } + @media (max-width: 768px) { + font-size: 16px; + padding: 12px 0; + } +`; + +const SmartButton = styled.button` + background-image: ${({ disabled = false }) => + disabled === true + ? 'none' + : 'linear-gradient(270deg, #ff8d00 0%, #bb5a00 100%);'}; + color: ${({ disabled = false }) => (disabled === true ? '#444;' : '#fff;')}; + background: ${({ disabled = false }) => + disabled === true ? '#e9eaed;' : ''}; + border: none; + transition: all 0.5s ease; + font-size: 18px; + width: 100%; + padding: 15px 0; + border-radius: 4px; + cursor: pointer; + outline: none; + margin-bottom: 20px; + :hover { + -webkit-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75); + } + svg { + fill: #444; + } + @media (max-width: 768px) { + font-size: 16px; + padding: 12px 0; + } +`; + +export default PrimaryButton; +export { SecondaryButton, SmartButton }; diff --git a/web/cashtab/src/components/Common/QRCode.js b/web/cashtab/src/components/Common/QRCode.js new file mode 100644 index 000000000..3f825067b --- /dev/null +++ b/web/cashtab/src/components/Common/QRCode.js @@ -0,0 +1,214 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import RawQRCode from 'qrcode.react'; +import { currency } from '../Common/Ticker.js'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import { Event } from '../../utils/GoogleAnalytics'; + +export const StyledRawQRCode = styled(RawQRCode)` + cursor: pointer; + border-radius: 23px; + background: #ffffff; + box-shadow: rgba(0, 0, 0, 0.01) 0px 0px 1px, rgba(0, 0, 0, 0.04) 0px 4px 8px, + rgba(0, 0, 0, 0.04) 0px 16px 24px, rgba(0, 0, 0, 0.01) 0px 24px 32px; + margin-bottom: 10px; + border: 1px solid #e9eaed; + path:first-child { + fill: #fff; + } + :hover { + border-color: ${({ bch = 0 }) => (bch === 1 ? '#ff8d00;' : '#5ebd6d')}; + } + @media (max-width: 768px) { + border-radius: 18px; + width: 170px; + height: 170px; + } +`; + +const Copied = styled.div` + font-size: 18px; + font-weight: bold; + width: 100%; + text-align: center; + + background-color: ${({ bch = 0 }) => (bch === 1 ? '#f59332;' : '#5ebd6d')}; + color: #fff; + position: absolute; + top: 65px; + padding: 30px 0; + @media (max-width: 768px) { + top: 52px; + padding: 20px 0; + } +`; + +const CustomInput = styled.div` + font-size: 15px; + color: #8e8e8e; + text-align: center; + cursor: pointer; + margin-bottom: 15px; + padding: 10px 0; + font-family: 'Roboto Mono', monospace; + border-radius: 5px; + + span { + font-weight: bold; + color: #444; + font-size: 16px; + } + input { + border: none; + width: 100%; + text-align: center; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + color: #444; + padding: 10px 0; + background: transparent; + margin-bottom: 15px; + display: none; + } + input:focus { + outline: none; + } + input::selection { + background: transparent; + color: #444; + } + @media (max-width: 768px) { + font-size: 11px; + span { + font-size: 12px; + } + input { + font-size: 11px; + margin-bottom: 10px; + } + } + @media (max-width: 340px) { + font-size: 10px; + span { + font-size: 11px; + } + input { + font-size: 11px; + margin-bottom: 10px; + } + } +`; + +export const QRCode = ({ + address, + size = 210, + onClick = () => null, + ...otherProps +}) => { + const [visible, setVisible] = useState(false); + const trimAmount = 6; + + const address_trim = address ? address.length - trimAmount : ''; + + const txtRef = React.useRef(null); + + const handleOnClick = evt => { + setVisible(true); + setTimeout(() => { + setVisible(false); + }, 1500); + onClick(evt); + }; + + const handleOnCopy = () => { + // Event.("Category", "Action", "Label") + // BCH or slp? + let eventLabel = currency.ticker; + if (address) { + const isToken = address.includes(currency.tokenPrefix); + if (isToken) { + eventLabel = currency.tokenTicker; + } + // Event('Category', 'Action', 'Label') + Event('Wallet', 'Copy Address', eventLabel); + } + + setVisible(true); + setTimeout(() => { + txtRef.current.select(); + }, 100); + }; + + return ( + +
+ + Copied + + + + + {address && ( + + + + {address.slice( + address.includes('bitcoin') ? 12 : 13, + address.includes('bitcoin') + ? 12 + trimAmount + : 13 + trimAmount, + )} + + {address.slice( + address.includes('bitcoin') + ? 12 + trimAmount + : 13 + trimAmount, + address.includes('bitcoin') + ? address_trim + : address_trim, + )} + {address.slice(-trimAmount)} + + )} +
+
+ ); +}; diff --git a/web/cashtab/src/components/Common/ScanQRCode.js b/web/cashtab/src/components/Common/ScanQRCode.js new file mode 100644 index 000000000..f09003367 --- /dev/null +++ b/web/cashtab/src/components/Common/ScanQRCode.js @@ -0,0 +1,168 @@ +import React, { useState } from 'react'; +import { Alert, Icon, Modal } from 'antd'; +import styled from 'styled-components'; +import { BrowserQRCodeReader } from '@zxing/library'; +import { currency } from '../Common/Ticker.js'; +import { Event } from '../../utils/GoogleAnalytics'; + +const StyledScanQRCode = styled.span` + display: block; +`; + +const StyledModal = styled(Modal)` + width: 400px !important; + height: 400px !important; + + .ant-modal-close { + top: 0 !important; + right: 0 !important; + } +`; + +const QRPreview = styled.video` + width: 100%; +`; + +export const ScanQRCode = ({ + width, + loadWithCameraOpen, + onScan = () => null, + ...otherProps +}) => { + const [visible, setVisible] = useState(loadWithCameraOpen); + const [error, setError] = useState(false); + // Use these states to debug video errors on mobile + // Note: iOS chrome/brave/firefox does not support accessing camera, will throw error + // iOS users can use safari + // todo only show scanner with safari + //const [mobileError, setMobileError] = useState(false); + //const [mobileErrorMsg, setMobileErrorMsg] = useState(false); + const [activeCodeReader, setActiveCodeReader] = useState(null); + + const teardownCodeReader = codeReader => { + if (codeReader !== null) { + codeReader.reset(); + codeReader.stop(); + codeReader = null; + setActiveCodeReader(codeReader); + } + }; + + const parseContent = content => { + let type = 'unknown'; + let values = {}; + + // If what scanner reads from QR code begins with 'bitcoincash:' or 'simpleledger:' or their successor prefixes + if ( + content.split(currency.prefix).length > 1 || + content.split(currency.tokenPrefix).length > 1 + ) { + type = 'address'; + values = { address: content }; + // Event("Category", "Action", "Label") + // Track number of successful QR code scans + // BCH or slp? + let eventLabel = currency.ticker; + const isToken = content.split(currency.tokenPrefix).length > 1; + if (isToken) { + eventLabel = currency.tokenTicker; + } + Event('ScanQRCode.js', 'Address Scanned', eventLabel); + } + return { type, values }; + }; + + const scanForQrCode = async () => { + const codeReader = new BrowserQRCodeReader(); + setActiveCodeReader(codeReader); + + try { + // Need to execute this before you can decode input + // eslint-disable-next-line no-unused-vars + const videoInputDevices = await codeReader.getVideoInputDevices(); + //console.log(`videoInputDevices`, videoInputDevices); + //setMobileError(JSON.stringify(videoInputDevices)); + + // choose your media device (webcam, frontal camera, back camera, etc.) + // TODO implement if necessary + //const selectedDeviceId = videoInputDevices[0].deviceId; + + //const previewElem = document.querySelector("#test-area-qr-code-webcam"); + + const content = await codeReader.decodeFromInputVideoDevice( + undefined, + 'test-area-qr-code-webcam', + ); + const result = parseContent(content.text); + + // stop scanning and fill form if it's an address + if (result.type === 'address') { + // Hide the scanner + setVisible(false); + onScan(result.values.address); + return teardownCodeReader(codeReader); + } + } catch (err) { + console.log(`Error in QR scanner:`); + console.log(err); + console.log(JSON.stringify(err.message)); + //setMobileErrorMsg(JSON.stringify(err.message)); + setError(err); + teardownCodeReader(codeReader); + } + + // stop scanning after 20s no matter what + }; + + React.useEffect(() => { + if (!visible) { + setError(false); + // Stop the camera if user closes modal + if (activeCodeReader !== null) { + teardownCodeReader(activeCodeReader); + } + } else { + scanForQrCode(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + return ( + <> + setVisible(!visible)} + > + + + setVisible(false)} + footer={null} + > + {visible ? ( +
+ {error ? ( + <> + + {/* +

{mobileError}

+

{mobileErrorMsg}

+ */} + + ) : ( + + )} +
+ ) : null} +
+ + ); +}; diff --git a/web/cashtab/src/components/Common/StyledCollapse.js b/web/cashtab/src/components/Common/StyledCollapse.js new file mode 100644 index 000000000..079fb4e30 --- /dev/null +++ b/web/cashtab/src/components/Common/StyledCollapse.js @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import { Collapse } from 'antd'; + +export const StyledCollapse = styled(Collapse)` + background: #fbfcfd !important; + border: 1px solid #eaedf3 !important; + + .ant-collapse-content { + border: 1px solid #eaedf3; + border-top: none; + } + + .ant-collapse-item { + border-bottom: none !important; + } + + * { + color: rgb(62, 63, 66) !important; + } +`; diff --git a/web/cashtab/src/components/Common/StyledOnBoarding.js b/web/cashtab/src/components/Common/StyledOnBoarding.js new file mode 100644 index 000000000..0ccb2cd39 --- /dev/null +++ b/web/cashtab/src/components/Common/StyledOnBoarding.js @@ -0,0 +1,57 @@ +import styled from 'styled-components'; + +const StyledOnBoarding = styled.div` + .ant-card { + background: #ffffff; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.04); + overflow: hidden; + + &:hover { + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1); + } + + * { + color: rgb(62, 63, 66); + } + + .ant-card-head { + color: #6e6e6e !important; + background: #fbfcfd; + border-bottom: 1px solid #eaedf3; + } + + .ant-alert { + background: #fbfcfd; + border: 1px solid #eaedf3; + } + } + .ant-card-body { + border: none; + } + .ant-collapse { + background: #fbfcfd; + border: 1px solid #eaedf3; + + .ant-collapse-content { + border: 1px solid #eaedf3; + border-top: none; + + .ant-collapse-content-box { + padding: 6px; + .ant-row.ant-form-item { + margin-bottom: 0px; + } + } + } + + .ant-collapse-item { + border-bottom: 1px solid #eaedf3; + } + + * { + color: rgb(62, 63, 66) !important; + } + } +`; + +export default StyledOnBoarding; diff --git a/web/cashtab/src/components/Common/StyledPage.js b/web/cashtab/src/components/Common/StyledPage.js new file mode 100644 index 000000000..a3de31c48 --- /dev/null +++ b/web/cashtab/src/components/Common/StyledPage.js @@ -0,0 +1,50 @@ +import styled from 'styled-components'; + +const StyledPage = styled.div` + .ant-card { + background: #ffffff; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.04); + overflow: hidden; + + &:hover { + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1); + } + + * { + color: rgb(62, 63, 66); + } + + .ant-card-head { + color: #6e6e6e !important; + background: #fbfcfd; + border-bottom: 1px solid #eaedf3; + } + + .ant-alert { + background: #fbfcfd; + border: 1px solid #eaedf3; + } + } + .ant-card-body { + border: none; + } + .ant-collapse { + background: #fbfcfd; + border: 1px solid #eaedf3; + + .ant-collapse-content { + border: 1px solid #eaedf3; + border-top: none; + } + + .ant-collapse-item { + border-bottom: 1px solid #eaedf3; + } + + * { + color: rgb(62, 63, 66) !important; + } + } +`; + +export default StyledPage; diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js new file mode 100644 index 000000000..707abc99f --- /dev/null +++ b/web/cashtab/src/components/Common/Ticker.js @@ -0,0 +1,19 @@ +import mainLogo from '../../assets/12-bitcoin-cash-square-crop.svg'; +import tokenLogo from '../../assets/simple-ledger-protocol-logo.png'; + +export const currency = { + name: 'Bitcoin ABC', + ticker: 'BCHA', + logo: mainLogo, + prefix: 'bitcoincash:', + coingeckoId: 'bitcoin-cash-abc-2', + defaultFee: 83.3, + blockExplorerUrl: 'https://explorer.bitcoinabc.org', + blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', + tokenName: 'Bitcoin ABC SLP', + tokenTicker: 'SLPA', + tokenLogo: tokenLogo, + tokenPrefix: 'simpleledger:', + tokenIconsUrl: '', //https://tokens.bitcoin.com/32 for BCH SLP + useBlockchainWs: false, +}; diff --git a/web/cashtab/src/components/Common/__mocks__/copy-to-clipboard.js b/web/cashtab/src/components/Common/__mocks__/copy-to-clipboard.js new file mode 100644 index 000000000..e7b0ccbff --- /dev/null +++ b/web/cashtab/src/components/Common/__mocks__/copy-to-clipboard.js @@ -0,0 +1,2 @@ +const copy = jest.fn(); +export default copy; diff --git a/web/cashtab/src/components/Common/__tests__/CustomIcons.test.js b/web/cashtab/src/components/Common/__tests__/CustomIcons.test.js new file mode 100644 index 000000000..d89f8ad71 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/CustomIcons.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { HammerIcon, PlaneIcon, FireIcon } from '../CustomIcons'; + +test('Render Custom Icon components', () => { + const renderedHammerIcon = renderer.create(); + let renderedHammerTree = renderedHammerIcon.toJSON(); + expect(renderedHammerTree).toMatchSnapshot(); + + const renderedPlaneIcon = renderer.create(); + let renderedPlaneIconTree = renderedPlaneIcon.toJSON(); + expect(renderedPlaneIconTree).toMatchSnapshot(); + + const renderedFireIcon = renderer.create(); + let renderedFileIconTree = renderedFireIcon.toJSON(); + expect(renderedFileIconTree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Common/__tests__/QRCode.test.js b/web/cashtab/src/components/Common/__tests__/QRCode.test.js new file mode 100644 index 000000000..715f535c1 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/QRCode.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { QRCode } from '../QRCode'; + +describe('', () => { + jest.useFakeTimers(); + + it('QRCode copying cash address', async () => { + const OnClick = jest.fn(); + const { container } = render( + , + ); + + const qrCodeElement = container.querySelector('#borderedQRCode'); + fireEvent.click(qrCodeElement); + + act(() => { + jest.runAllTimers(); + }); + expect(OnClick).toHaveBeenCalled(); + expect(setTimeout).toHaveBeenCalled(); + }); + + it('QRCode copying SLP address', () => { + const OnClick = jest.fn(); + const { container } = render( + , + ); + const qrCodeElement = container.querySelector('#borderedQRCode'); + fireEvent.click(qrCodeElement); + expect(OnClick).toHaveBeenCalled(); + }); + + it('QRCode without address', () => { + const { container } = render(); + + const qrCodeElement = container.querySelector('#borderedQRCode'); + fireEvent.click(qrCodeElement); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1500); + expect(setTimeout).toHaveBeenCalled(); + }); +}); diff --git a/web/cashtab/src/components/Common/__tests__/StyledCollapse.test.js b/web/cashtab/src/components/Common/__tests__/StyledCollapse.test.js new file mode 100644 index 000000000..dfa77aa85 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/StyledCollapse.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { StyledCollapse } from '../StyledCollapse'; + +test('Render StyledCollapse component', () => { + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Common/__tests__/StyledOnBoarding.test.js b/web/cashtab/src/components/Common/__tests__/StyledOnBoarding.test.js new file mode 100644 index 000000000..34c3fa1e9 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/StyledOnBoarding.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import StyledOnBoarding from '../StyledOnBoarding'; + +test('Render StyledOnBoarding component', () => { + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Common/__tests__/StyledPage.test.js b/web/cashtab/src/components/Common/__tests__/StyledPage.test.js new file mode 100644 index 000000000..f9047f1d5 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/StyledPage.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import StyledPage from '../StyledPage'; + +test('Render StyledPage component', () => { + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Common/__tests__/__snapshots__/CustomIcons.test.js.snap b/web/cashtab/src/components/Common/__tests__/__snapshots__/CustomIcons.test.js.snap new file mode 100644 index 000000000..45d76658b --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/__snapshots__/CustomIcons.test.js.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render Custom Icon components 1`] = ` + + + + + +`; + +exports[`Render Custom Icon components 2`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`Render Custom Icon components 3`] = ` + + + +`; diff --git a/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap new file mode 100644 index 000000000..67f50c564 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render StyledCollapse component 1`] = ` +
+`; diff --git a/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledOnBoarding.test.js.snap b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledOnBoarding.test.js.snap new file mode 100644 index 000000000..71ec9dc52 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledOnBoarding.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render StyledOnBoarding component 1`] = ` +
+`; diff --git a/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledPage.test.js.snap b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledPage.test.js.snap new file mode 100644 index 000000000..bf718e704 --- /dev/null +++ b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledPage.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render StyledPage component 1`] = ` +
+`; diff --git a/web/cashtab/src/components/Configure/Configure.js b/web/cashtab/src/components/Configure/Configure.js new file mode 100644 index 000000000..979280710 --- /dev/null +++ b/web/cashtab/src/components/Configure/Configure.js @@ -0,0 +1,555 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { Icon, Collapse, Form, Input, Modal } from 'antd'; +import { CashSpin, CashSpinIcon } from '../Common/CustomSpinner'; +import { WalletContext } from '../../utils/context'; +import { StyledCollapse } from '../Common/StyledCollapse'; +import PrimaryButton, { + SecondaryButton, + SmartButton, +} from '../Common/PrimaryButton'; +import { CashLoader } from '../Common/CustomIcons'; +import { ReactComponent as Trashcan } from '../../assets/trashcan.svg'; +import { ReactComponent as Edit } from '../../assets/edit.svg'; +import { Event } from '../../utils/GoogleAnalytics'; + +const { Panel } = Collapse; + +const SWRow = styled.div` + border-radius: 3px; + padding: 10px 0; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + @media (max-width: 500px) { + flex-direction: column; + margin-bottom: 12px; + } +`; + +const SWName = styled.div` + width: 50%; + display: flex; + align-items: center; + justify-content: space-between; + word-wrap: break-word; + hyphens: auto; + @media (max-width: 500px) { + width: 100%; + justify-content: center; + margin-bottom: 15px; + } + + h3 { + font-size: 16px; + color: #444; + margin: 0; + text-align: left; + } +`; + +const SWButtonCtn = styled.div` + width: 50%; + display: flex; + align-items: center; + justify-content: flex-end; + @media (max-width: 500px) { + width: 100%; + justify-content: center; + } + + button { + cursor: pointer; + + @media (max-width: 768px) { + font-size: 14px; + } + } + + svg { + stroke: #444; + fill: #444; + width: 25px; + height: 25px; + margin-right: 20px; + cursor: pointer; + + :first-child:hover { + stroke: #ff8d00; + fill: #ff8d00; + } + :hover { + stroke: red; + fill: red; + } + } +`; + +const AWRow = styled.div` + padding: 10px 0; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + h3 { + font-size: 16px; + display: inline-block; + color: #444; + margin: 0; + text-align: left; + font-weight: bold; + } + h4 { + font-size: 16px; + display: inline-block; + color: #ff8d00 !important; + margin: 0; + text-align: right; + } + @media (max-width: 500px) { + flex-direction: column; + margin-bottom: 12px; + } +`; + +const StyledConfigure = styled.div` + h2 { + color: #444; + font-size: 25px; + } + p { + color: #444; + } +`; + +const StyledSpacer = styled.div` + height: 1px; + width: 100%; + background-color: #e2e2e2; + margin: 60px 0 50px; +`; + +export default () => { + const ContextValue = React.useContext(WalletContext); + const { wallet, loading, apiError } = ContextValue; + + const { + addNewSavedWallet, + activateWallet, + renameWallet, + deleteWallet, + validateMnemonic, + getSavedWallets, + } = ContextValue; + const [savedWallets, setSavedWallets] = useState([]); + const [formData, setFormData] = useState({ + dirty: true, + mnemonic: '', + }); + const [showRenameWalletModal, setShowRenameWalletModal] = useState(false); + const [showDeleteWalletModal, setShowDeleteWalletModal] = useState(false); + const [walletToBeRenamed, setWalletToBeRenamed] = useState(null); + const [walletToBeDeleted, setWalletToBeDeleted] = useState(null); + const [newWalletName, setNewWalletName] = useState(''); + const [ + confirmationOfWalletToBeDeleted, + setConfirmationOfWalletToBeDeleted, + ] = useState(''); + const [newWalletNameIsValid, setNewWalletNameIsValid] = useState(null); + const [walletDeleteValid, setWalletDeleteValid] = useState(null); + const [seedInput, openSeedInput] = useState(false); + + const showPopulatedDeleteWalletModal = walletInfo => { + setWalletToBeDeleted(walletInfo); + setShowDeleteWalletModal(true); + }; + + const showPopulatedRenameWalletModal = walletInfo => { + setWalletToBeRenamed(walletInfo); + setShowRenameWalletModal(true); + }; + const cancelRenameWallet = () => { + // Delete form value + setNewWalletName(''); + setShowRenameWalletModal(false); + }; + const cancelDeleteWallet = () => { + setWalletToBeDeleted(null); + setConfirmationOfWalletToBeDeleted(''); + setShowDeleteWalletModal(false); + }; + const updateSavedWallets = async activeWallet => { + if (activeWallet) { + const savedWallets = await getSavedWallets(activeWallet); + setSavedWallets(savedWallets); + } + }; + + const [isValidMnemonic, setIsValidMnemonic] = useState(false); + + useEffect(() => { + // Update savedWallets every time the active wallet changes + updateSavedWallets(wallet); + }, [wallet]); + + // Need this function to ensure that savedWallets are updated on new wallet creation + const updateSavedWalletsOnCreate = async importMnemonic => { + // Event("Category", "Action", "Label") + // Track number of times a different wallet is activated + Event('Configure.js', 'Create Wallet', 'New'); + const walletAdded = await addNewSavedWallet(importMnemonic); + if (!walletAdded) { + Modal.error({ + title: 'This wallet already exists!', + content: 'Wallet not added', + }); + } else { + Modal.success({ + content: 'Wallet added to your saved wallets', + }); + } + await updateSavedWallets(wallet); + }; + // Same here + // TODO you need to lock UI here until this is complete + // Otherwise user may try to load an already-loading wallet, wreak havoc with indexedDB + const updateSavedWalletsOnLoad = async walletToActivate => { + // Event("Category", "Action", "Label") + // Track number of times a different wallet is activated + Event('Configure.js', 'Activate', ''); + await activateWallet(walletToActivate); + await updateSavedWallets(wallet); + }; + + async function submit() { + setFormData({ + ...formData, + dirty: false, + }); + + // Exit if no user input + if (!formData.mnemonic) { + return; + } + + // Exit if mnemonic is invalid + if (!isValidMnemonic) { + return; + } + // Event("Category", "Action", "Label") + // Track number of times a different wallet is activated + Event('Configure.js', 'Create Wallet', 'Imported'); + updateSavedWalletsOnCreate(formData.mnemonic); + } + + const handleChange = e => { + const { value, name } = e.target; + + // Validate mnemonic on change + // Import button should be disabled unless mnemonic is valid + setIsValidMnemonic(validateMnemonic(value)); + + setFormData(p => ({ ...p, [name]: value })); + }; + + const changeWalletName = async () => { + if (newWalletName === '' || newWalletName.length > 24) { + setNewWalletNameIsValid(false); + return; + } + // Hide modal + setShowRenameWalletModal(false); + // Change wallet name + console.log( + `Changing wallet ${walletToBeRenamed.name} name to ${newWalletName}`, + ); + const renameSuccess = await renameWallet( + walletToBeRenamed.name, + newWalletName, + ); + + if (renameSuccess) { + Modal.success({ + content: `Wallet "${walletToBeRenamed.name}" renamed to "${newWalletName}"`, + }); + } else { + Modal.error({ + content: `Rename failed. All wallets must have a unique name.`, + }); + } + await updateSavedWallets(wallet); + // Clear wallet name for form + setNewWalletName(''); + }; + + const deleteSelectedWallet = async () => { + if (!walletDeleteValid) { + return; + } + if ( + confirmationOfWalletToBeDeleted !== + `delete ${walletToBeDeleted.name}` + ) { + setWalletDeleteValid(false); + return; + } + + // Hide modal + setShowDeleteWalletModal(false); + // Change wallet name + console.log(`Deleting wallet "${walletToBeDeleted.name}"`); + const walletDeletedSuccess = await deleteWallet(walletToBeDeleted); + + if (walletDeletedSuccess) { + Modal.success({ + content: `Wallet "${walletToBeDeleted.name}" successfully deleted`, + }); + } else { + Modal.error({ + content: `Error deleting ${walletToBeDeleted.name}.`, + }); + } + await updateSavedWallets(wallet); + // Clear wallet delete confirmation from form + setConfirmationOfWalletToBeDeleted(''); + }; + + const handleWalletNameInput = e => { + const { value } = e.target; + // validation + if (value && value.length && value.length < 24) { + setNewWalletNameIsValid(true); + } else { + setNewWalletNameIsValid(false); + } + + setNewWalletName(value); + }; + + const handleWalletToDeleteInput = e => { + const { value } = e.target; + + if (value && value === `delete ${walletToBeDeleted.name}`) { + setWalletDeleteValid(true); + } else { + setWalletDeleteValid(false); + } + setConfirmationOfWalletToBeDeleted(value); + }; + + return ( + + + {walletToBeRenamed !== null && ( + cancelRenameWallet()} + > +
+ + } + placeholder="Enter new wallet name" + name="newName" + value={newWalletName} + onChange={e => handleWalletNameInput(e)} + /> + +
+
+ )} + {walletToBeDeleted !== null && ( + cancelDeleteWallet()} + > +
+ + } + placeholder={`Type "delete ${walletToBeDeleted.name}" to confirm`} + name="walletToBeDeletedInput" + value={confirmationOfWalletToBeDeleted} + onChange={e => handleWalletToDeleteInput(e)} + /> + +
+
+ )} + +

+ Seed Phrase +

+

+ Your seed phrase can be used to restore your wallet in case + the original instance of it is destroyed. We highly + recommend always making a copy of your seed phrase and + keeping it somewhere safe. +

+ {wallet && wallet.mnemonic && ( + + +

+ {wallet && wallet.mnemonic + ? wallet.mnemonic + : ''} +

+
+
+ )} + + {savedWallets && savedWallets.length > 0 && ( + <> + + + + +

{wallet.name}

+

Currently active

+
+
+ {savedWallets.map(sw => ( + + +

{sw.name}

+
+ + + + showPopulatedRenameWalletModal( + sw, + ) + } + /> + + showPopulatedDeleteWalletModal( + sw, + ) + } + /> + + +
+ ))} +
+
+
+ + )} + + + {apiError ? ( + <> + +

+ An error occured on our end. Reconnecting... +

+ + ) : ( + <> + updateSavedWalletsOnCreate()} + > + New Wallet + + openSeedInput(!seedInput)} + > + Import Wallet + + {seedInput && ( + <> +

+ Copy and paste your mnemonic seed phrase + below to import an existing wallet +

+
+ + } + placeholder="mnemonic (seed phrase)" + name="mnemonic" + onChange={e => handleChange(e)} + required + /> + + submit()} + > + Import + +
+ + )} + + )} +
+
+ ); +}; diff --git a/web/cashtab/src/components/Configure/__tests__/Configure.test.js b/web/cashtab/src/components/Configure/__tests__/Configure.test.js new file mode 100644 index 000000000..54f7047de --- /dev/null +++ b/web/cashtab/src/components/Configure/__tests__/Configure.test.js @@ -0,0 +1,26 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import Configure from '../Configure'; +let realUseContext; +let useContextMock; +beforeEach(() => { + realUseContext = React.useContext; + useContextMock = React.useContext = jest.fn(); +}); +afterEach(() => { + React.useContext = realUseContext; +}); + +test('Configure without a wallet', () => { + useContextMock.mockReturnValue({ wallet: undefined }); + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Configure with a wallet', () => { + useContextMock.mockReturnValue({ wallet: { mnemonic: 'test mnemonic' } }); + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap new file mode 100644 index 000000000..c69c342a1 --- /dev/null +++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap @@ -0,0 +1,293 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Configure with a wallet 1`] = ` +
+
+
+ + + +
+
+
+
+

+ + + + Seed Phrase +

+

+ Your seed phrase can be used to restore your wallet in case the original instance of it is destroyed. We highly recommend always making a copy of your seed phrase and keeping it somewhere safe. +

+
+
+ +
+
+
+ + +
+
+
+`; + +exports[`Configure without a wallet 1`] = ` +
+
+
+ + + +
+
+
+
+

+ + + + Seed Phrase +

+

+ Your seed phrase can be used to restore your wallet in case the original instance of it is destroyed. We highly recommend always making a copy of your seed phrase and keeping it somewhere safe. +

+
+ + +
+
+
+`; diff --git a/web/cashtab/src/components/NotFound.js b/web/cashtab/src/components/NotFound.js new file mode 100644 index 000000000..af2f67bf8 --- /dev/null +++ b/web/cashtab/src/components/NotFound.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { Row, Col } from 'antd'; + +export default () => ( + + +

Page not found

+ +
+); diff --git a/web/cashtab/src/components/OnBoarding/OnBoarding.js b/web/cashtab/src/components/OnBoarding/OnBoarding.js new file mode 100644 index 000000000..caed56899 --- /dev/null +++ b/web/cashtab/src/components/OnBoarding/OnBoarding.js @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { WalletContext } from '../../utils/context'; +import { Input, Icon, Form, Modal } from 'antd'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import StyledOnboarding from '../Common/StyledOnBoarding'; +import PrimaryButton, { + SecondaryButton, + SmartButton, +} from '../Common/PrimaryButton'; +import { currency } from '../Common/Ticker.js'; +import { Event } from '../../utils/GoogleAnalytics'; + +export const WelcomeText = styled.p` + color: #444; + width: 100%; + font-size: 16px; + margin-bottom: 60px; + text-align: left; +`; + +export const OnBoarding = ({ history }) => { + const ContextValue = React.useContext(WalletContext); + const { createWallet, validateMnemonic } = ContextValue; + const [formData, setFormData] = useState({ + dirty: true, + mnemonic: '', + }); + + const [seedInput, openSeedInput] = useState(false); + const [isValidMnemonic, setIsValidMnemonic] = useState(false); + const { confirm } = Modal; + + async function submit() { + setFormData({ + ...formData, + dirty: false, + }); + + if (!formData.mnemonic) { + return; + } + // Event("Category", "Action", "Label") + // Track number of created wallets from onboarding + Event('Onboarding.js', 'Create Wallet', 'Imported'); + createWallet(formData.mnemonic); + } + + const handleChange = e => { + const { value, name } = e.target; + + // Validate mnemonic on change + // Import button should be disabled unless mnemonic is valid + setIsValidMnemonic(validateMnemonic(value)); + + setFormData(p => ({ ...p, [name]: value })); + }; + + function showBackupConfirmModal() { + confirm({ + title: "Don't forget to back up your wallet", + icon: , + content: `Once your wallet is created you can back it up by writing down your 12-word seed. You can find your seed on the Settings page. If you are browsing in Incognito mode or if you clear your browser history, you will lose any funds that are not backed up!`, + okText: 'Okay, make me a wallet!', + onOk() { + // Event("Category", "Action", "Label") + // Track number of created wallets from onboarding + Event('Onboarding.js', 'Create Wallet', 'New'); + createWallet(); + }, + }); + } + + return ( + <> + + Welcome to CashTab! CashTab is an open source, non-custodial web + wallet for {currency.name}. +
+
+ Web wallets offer user convenience, but storing large amounts of + money on a web wallet is not recommended. +
+
+ Create a new wallet below to get started, or import an existing + wallet using a seed phrase. +
+ + showBackupConfirmModal()}> + New Wallet + + + openSeedInput(!seedInput)}> + Import Wallet + + {seedInput && ( + +
+ + } + placeholder="mnemonic (seed phrase)" + name="mnemonic" + onChange={e => handleChange(e)} + required + /> + + + submit()} + > + Import + +
+
+ )} + + ); +}; diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js new file mode 100644 index 000000000..d63a75928 --- /dev/null +++ b/web/cashtab/src/components/Send/Send.js @@ -0,0 +1,374 @@ +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { WalletContext } from '../../utils/context'; +import { Form, notification, message } from 'antd'; +import { CashLoader } from '../Common/CustomIcons'; +import { CashSpin, CashSpinIcon } from '../Common/CustomSpinner'; +import { Row, Col } from 'antd'; +import Paragraph from 'antd/lib/typography/Paragraph'; +import PrimaryButton, { SecondaryButton } from '../Common/PrimaryButton'; +import { + SendBchInput, + FormItemWithQRCodeAddon, +} from '../Common/EnhancedInputs'; +import useBCH from '../../hooks/useBCH'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import { isMobile, isIOS, isSafari } from 'react-device-detect'; +import { currency } from '../Common/Ticker.js'; +import { Event } from '../../utils/GoogleAnalytics'; +export const BalanceHeader = styled.div` + p { + color: #777; + width: 100%; + font-size: 14px; + margin-bottom: 0px; + } + + h3 { + color: #444; + width: 100%; + font-size: 26px; + font-weight: bold; + margin-bottom: 0px; + } +`; + +export const BalanceHeaderFiat = styled.div` + color: #444; + width: 100%; + font-size: 18px; + margin-bottom: 20px; + font-weight: bold; + @media (max-width: 768px) { + font-size: 16px; + } +`; + +export const ZeroBalanceHeader = styled.div` + color: #444; + width: 100%; + font-size: 14px; + margin-bottom: 20px; +`; + +const ConvertAmount = styled.div` + color: #777; + width: 100%; + font-size: 14px; + margin-bottom: 10px; + font-weight: bold; + @media (max-width: 768px) { + font-size: 12px; + } +`; + +const SendBCH = ({ filledAddress, callbackTxId }) => { + const { + wallet, + fiatPrice, + balances, + slpBalancesAndUtxos, + apiError, + } = React.useContext(WalletContext); + + // Get device window width + // If this is less than 769, the page will open with QR scanner open + const { width } = useWindowDimensions(); + // Load with QR code open if device is mobile and NOT iOS + anything but safari + const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); + + const [formData, setFormData] = useState({ + dirty: true, + value: '', + address: filledAddress || '', + }); + const [loading, setLoading] = useState(false); + const [sendBchAmountError, setSendBchAmountError] = useState(false); + const [selectedCurrency, setSelectedCurrency] = useState(currency.ticker); + + const { getBCH, getRestUrl, sendBch, calcFee } = useBCH(); + const BCH = getBCH(); + + // If the balance has changed, unlock the UI + // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked + useEffect(() => { + setLoading(false); + }, [balances.totalBalance]); + + async function submit() { + setFormData({ + ...formData, + dirty: false, + }); + + if ( + !formData.address || + !formData.value || + Number(formData.value) <= 0 + ) { + return; + } + + // Event("Category", "Action", "Label") + // Track number of BCHA send transactions and whether users + // are sending BCHA or USD + Event('Send.js', 'Send', selectedCurrency); + + setLoading(true); + const { address, value } = formData; + + // Calculate the amount in BCH + let bchValue = value; + + if (selectedCurrency === 'USD') { + bchValue = (value / fiatPrice).toFixed(8); + } + + try { + const link = await sendBch( + BCH, + wallet, + slpBalancesAndUtxos.nonSlpUtxos, + { + addresses: [filledAddress || address], + values: [bchValue], + }, + callbackTxId, + ); + + notification.success({ + message: 'Success', + description: ( + + + Transaction successful. Click or tap here for more + details + + + ), + duration: 5, + }); + } catch (e) { + // Set loading to false here as well, as balance may not change depending on where error occured in try loop + setLoading(false); + let message; + + if (!e.error && !e.message) { + message = `Transaction failed: no response from ${getRestUrl()}.`; + } else if ( + /Could not communicate with full node or other external service/.test( + e.error, + ) + ) { + message = 'Could not communicate with API. Please try again.'; + } else if ( + e.error && + e.error.includes( + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', + ) + ) { + message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; + } else { + message = e.message || e.error || JSON.stringify(e); + } + + notification.error({ + message: 'Error', + description: message, + duration: 5, + }); + console.error(e); + } + } + + const handleChange = e => { + const { value, name } = e.target; + + setFormData(p => ({ ...p, [name]: value })); + }; + + const handleSelectedCurrencyChange = e => { + setSelectedCurrency(e); + // Clear input field to prevent accidentally sending 1 BCH instead of 1 USD + setFormData(p => ({ ...p, value: '' })); + }; + + const handleBchAmountChange = e => { + const { value, name } = e.target; + let error = false; + let bchValue = value; + + if (selectedCurrency === 'USD') { + bchValue = (value / fiatPrice).toFixed(8); + } + + // Validate value for > 0 + if (isNaN(bchValue)) { + error = 'Amount must be a number'; + } else if (bchValue <= 0) { + error = 'Amount must be greater than 0'; + } else if (bchValue < 0.00001) { + error = `Send amount must be at least 0.00001 ${currency.ticker}`; + } else if (bchValue > balances.totalBalance) { + error = `Amount cannot exceed your ${currency.ticker} balance`; + } else if (!isNaN(bchValue) && bchValue.toString().includes('.')) { + if (bchValue.toString().split('.')[1].length > 8) { + error = `${currency.ticker} transactions do not support more than 8 decimal places`; + } + } + setSendBchAmountError(error); + + setFormData(p => ({ ...p, [name]: value })); + }; + + const onMax = async () => { + // Clear amt error + setSendBchAmountError(false); + // Set currency to BCH + setSelectedCurrency(currency.ticker); + try { + const txFeeSats = calcFee(BCH, slpBalancesAndUtxos.nonSlpUtxos); + + const txFeeBch = txFeeSats / 1e8; + let value = + balances.totalBalance - txFeeBch >= 0 + ? (balances.totalBalance - txFeeBch).toFixed(8) + : 0; + + setFormData({ + ...formData, + value, + }); + } catch (err) { + console.log(`Error in onMax:`); + console.log(err); + message.error( + 'Unable to calculate the max value due to network errors', + ); + } + }; + // Display price in USD below input field for send amount, if it can be calculated + let fiatPriceString = ''; + if (fiatPrice !== null && !isNaN(formData.value)) { + if (selectedCurrency === currency.ticker) { + fiatPriceString = `$ ${(fiatPrice * Number(formData.value)).toFixed( + 2, + )} USD`; + } else { + fiatPriceString = `${(Number(formData.value) / fiatPrice).toFixed( + 8, + )} ${currency.ticker}`; + } + } + + return ( + <> + {!balances.totalBalance ? ( + + You currently have 0 {currency.ticker} +
+ Deposit some funds to use this feature +
+ ) : ( + <> + +

Available balance

+

+ {balances.totalBalance} {currency.ticker} +

+
+ {fiatPrice !== null && ( + + ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} + USD + + )} + + )} + + + + +
+ + setFormData({ + ...formData, + address: result, + }) + } + inputProps={{ + disabled: Boolean(filledAddress), + placeholder: `${currency.ticker} Address`, + name: 'address', + onChange: e => handleChange(e), + required: true, + value: filledAddress || formData.address, + }} + > + handleBchAmountChange(e), + required: true, + value: formData.value, + }} + selectProps={{ + value: selectedCurrency, + onChange: e => + handleSelectedCurrencyChange(e), + }} + > + = {fiatPriceString} +
+ {!balances.totalBalance || + apiError || + sendBchAmountError ? ( + Send + ) : ( + submit()}> + Send + + )} +
+ {apiError && ( + <> + +

+ + An error occured on our end. + Reconnecting... + +

+ + )} + +
+ +
+ + ); +}; + +export default SendBCH; diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js new file mode 100644 index 000000000..cdffd0145 --- /dev/null +++ b/web/cashtab/src/components/Send/SendToken.js @@ -0,0 +1,319 @@ +import React, { useState, useEffect } from 'react'; +import { WalletContext } from '../../utils/context'; +import { Alert, Form, notification, message } from 'antd'; +import { CashSpin, CashSpinIcon } from '../Common/CustomSpinner'; +import { Row, Col } from 'antd'; +import Paragraph from 'antd/lib/typography/Paragraph'; +import PrimaryButton, { SecondaryButton } from '../Common/PrimaryButton'; +import { CashLoader } from '../Common/CustomIcons'; +import { + FormItemWithMaxAddon, + FormItemWithQRCodeAddon, +} from '../Common/EnhancedInputs'; +import useBCH from '../../hooks/useBCH'; +import { BalanceHeader } from './Send'; +import { Redirect } from 'react-router-dom'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import { isMobile, isIOS, isSafari } from 'react-device-detect'; +import { Img } from 'react-image'; +import makeBlockie from 'ethereum-blockies-base64'; +import BigNumber from 'bignumber.js'; +import { currency } from '../Common/Ticker.js'; +import { Event } from '../../utils/GoogleAnalytics'; + +const SendToken = ({ tokenId }) => { + const { wallet, tokens, slpBalancesAndUtxos, apiError } = React.useContext( + WalletContext, + ); + const token = tokens.find(token => token.tokenId === tokenId); + const [sendTokenAmountError, setSendTokenAmountError] = useState(false); + + // Get device window width + // If this is less than 769, the page will open with QR scanner open + const { width } = useWindowDimensions(); + // Load with QR code open if device is mobile and NOT iOS + anything but safari + const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); + + const [formData, setFormData] = useState({ + dirty: true, + value: '', + address: '', + }); + const [loading, setLoading] = useState(false); + + const { getBCH, getRestUrl, sendToken } = useBCH(); + const BCH = getBCH(); + + async function submit() { + setFormData({ + ...formData, + dirty: false, + }); + + if ( + !formData.address || + !formData.value || + Number(formData.value <= 0) || + sendTokenAmountError + ) { + return; + } + + // Event("Category", "Action", "Label") + // Track number of SLPA send transactions and + // SLPA token IDs + Event('SendToken.js', 'Send', tokenId); + + setLoading(true); + const { address, value } = formData; + + try { + const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { + tokenId: tokenId, + tokenReceiverAddress: address, + amount: value, + }); + + notification.success({ + message: 'Success', + description: ( + + + Transaction successful. Click or tap here for more + details + + + ), + duration: 5, + }); + } catch (e) { + setLoading(false); + let message; + + if (!e.error && !e.message) { + message = `Transaction failed: no response from ${getRestUrl()}.`; + } else if ( + /Could not communicate with full node or other external service/.test( + e.error, + ) + ) { + message = 'Could not communicate with API. Please try again.'; + } else { + message = e.message || e.error || JSON.stringify(e); + } + console.log(e); + notification.error({ + message: 'Error', + description: message, + duration: 3, + }); + console.error(e); + } + } + + const handleSlpAmountChange = e => { + let error = false; + const { value, name } = e.target; + + // test if exceeds balance using BigNumber + let isGreaterThanBalance = false; + if (!isNaN(value)) { + const bigValue = new BigNumber(value); + // Returns 1 if greater, -1 if less, 0 if the same, null if n/a + isGreaterThanBalance = bigValue.comparedTo(token.balance); + } + + // Validate value for > 0 + if (isNaN(value)) { + error = 'Amount must be a number'; + } else if (value <= 0) { + error = 'Amount must be greater than 0'; + } else if (token && token.balance && isGreaterThanBalance === 1) { + error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; + } else if (!isNaN(value) && value.toString().includes('.')) { + if (value.toString().split('.')[1].length > token.info.decimals) { + error = `This token only supports ${token.info.decimals} decimal places`; + } + } + setSendTokenAmountError(error); + setFormData(p => ({ ...p, [name]: value })); + }; + + const handleChange = e => { + const { value, name } = e.target; + + setFormData(p => ({ ...p, [name]: value })); + }; + + const onMax = async () => { + // Clear this error before updating field + setSendTokenAmountError(false); + try { + let value = token.balance; + + setFormData({ + ...formData, + value, + }); + } catch (err) { + console.log(`Error in onMax:`); + console.log(err); + message.error( + 'Unable to calculate the max value due to network errors', + ); + } + }; + + useEffect(() => { + // If the balance has changed, unlock the UI + // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked + + setLoading(false); + }, [token]); + + return ( + <> + {!token && } + + {token && ( + <> + +

Available balance

+

+ {token.balance.toString()} {token.info.tokenTicker} +

+
+ + + + +
+ + setFormData({ + ...formData, + address: result, + }) + } + inputProps={{ + placeholder: `${currency.tokenTicker} Address`, + name: 'address', + onChange: e => handleChange(e), + required: true, + value: formData.address, + }} + /> + + } + /> + ) : ( + {`identicon + ), + suffix: token.info.tokenTicker, + onChange: e => + handleSlpAmountChange(e), + required: true, + value: formData.value, + }} + /> +
+ {apiError || sendTokenAmountError ? ( + <> + + Send {token.info.tokenName} + + {apiError && } + + ) : ( + submit()} + > + Send {token.info.tokenName} + + )} +
+ {apiError && ( +

+ + An error occured on our end. + Reconnecting... + +

+ )} + + +
+ +
+ + )} + + ); +}; + +export default SendToken; diff --git a/web/cashtab/src/components/Wallet/TokenList.js b/web/cashtab/src/components/Wallet/TokenList.js new file mode 100644 index 000000000..749445efa --- /dev/null +++ b/web/cashtab/src/components/Wallet/TokenList.js @@ -0,0 +1,23 @@ +import React from 'react'; +import TokenListItem from './TokenListItem'; +import { Link } from 'react-router-dom'; +import { currency } from '../Common/Ticker.js'; + +const TokenList = ({ tokens }) => { + return ( +
+

{currency.tokenTicker} Tokens

+ {tokens.map(token => ( + + + + ))} +
+ ); +}; + +export default TokenList; diff --git a/web/cashtab/src/components/Wallet/TokenListItem.js b/web/cashtab/src/components/Wallet/TokenListItem.js new file mode 100644 index 000000000..ecb8797cb --- /dev/null +++ b/web/cashtab/src/components/Wallet/TokenListItem.js @@ -0,0 +1,75 @@ +import React from 'react'; +import styled from 'styled-components'; +import makeBlockie from 'ethereum-blockies-base64'; +import { Img } from 'react-image'; +import { currency } from '../Common/Ticker'; + +const TokenIcon = styled.div` + height: 32px; + width: 32px; +`; + +const BalanceAndTicker = styled.div` + font-size: 1rem; +`; + +const Wrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 25px; + border-radius: 3px; + background: #ffffff; + margin-bottom: 3px; + box-shadow: rgba(0, 0, 0, 0.01) 0px 0px 1px, rgba(0, 0, 0, 0.04) 0px 4px 8px, + rgba(0, 0, 0, 0.04) 0px 16px 24px, rgba(0, 0, 0, 0.01) 0px 24px 32px; + border: 1px solid #e9eaed; + + :hover { + border-color: #5ebd6d; + } +`; + +const TokenListItem = ({ ticker, balance, tokenId }) => { + return ( + + + {currency.tokenIconsUrl !== '' ? ( + {`identicon + } + /> + ) : ( + {`identicon + )} + + + {balance} {ticker} + + + ); +}; + +export default TokenListItem; diff --git a/web/cashtab/src/components/Wallet/Wallet.js b/web/cashtab/src/components/Wallet/Wallet.js new file mode 100644 index 000000000..6b4083361 --- /dev/null +++ b/web/cashtab/src/components/Wallet/Wallet.js @@ -0,0 +1,269 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Icon } from 'antd'; +import { WalletContext } from '../../utils/context'; +import { OnBoarding } from '../OnBoarding/OnBoarding'; +import { QRCode } from '../Common/QRCode'; +import { currency } from '../Common/Ticker.js'; +import { Link } from 'react-router-dom'; +import TokenList from './TokenList'; +import { CashLoader } from '../Common/CustomIcons'; + +export const LoadingCtn = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; + height: 400px; + flex-direction: column; + + svg { + width: 50px; + height: 50px; + fill: #ff8d00; + } +`; + +export const BalanceHeader = styled.div` + color: #444; + width: 100%; + font-size: 30px; + font-weight: bold; + @media (max-width: 768px) { + font-size: 23px; + } +`; + +export const BalanceHeaderFiat = styled.div` + color: #444; + width: 100%; + font-size: 18px; + margin-bottom: 20px; + font-weight: bold; + @media (max-width: 768px) { + font-size: 16px; + } +`; + +export const ZeroBalanceHeader = styled.div` + color: #444; + width: 100%; + font-size: 14px; + margin-bottom: 5px; +`; + +export const SwitchBtnCtn = styled.div` + display: flex; + align-items: center; + justify-content: center; + align-content: space-between; + margin-bottom: 15px; + .nonactiveBtn { + color: #444; + background: linear-gradient(145deg, #eeeeee, #c8c8c8) !important; + box-shadow: none !important; + } + .slpActive { + background: #5ebd6d !important; + box-shadow: inset 5px 5px 11px #4e9d5a, inset -5px -5px 11px #6edd80 !important; + } +`; + +export const SwitchBtn = styled.div` + font-weight: bold; + display: inline-block; + cursor: pointer; + color: #ffffff; + font-size: 14px; + padding: 6px 0; + width: 100px; + margin: 0 1px; + text-decoration: none; + background: #ff8d00; + box-shadow: inset 8px 8px 16px #d67600, inset -8px -8px 16px #ffa400; + user-select: none; + :first-child { + border-radius: 100px 0 0 100px; + } + :nth-child(2) { + border-radius: 0 100px 100px 0; + } +`; + +export const Links = styled(Link)` + color: #444; + width: 100%; + font-size: 16px; + margin: 10px 0 20px 0; + border: 1px solid #444; + padding: 14px 0; + display: inline-block; + border-radius: 3px; + transition: all 200ms ease-in-out; + svg { + fill: #444; + } + :hover { + color: #ff8d00; + border-color: #ff8d00; + svg { + fill: #444; + } + } + @media (max-width: 768px) { + padding: 10px 0; + font-size: 14px; + } +`; + +export const ExternalLink = styled.a` + color: #444; + width: 100%; + font-size: 16px; + margin: 0 0 20px 0; + border: 1px solid #444; + padding: 14px 0; + display: inline-block; + border-radius: 3px; + transition: all 200ms ease-in-out; + svg { + fill: #444; + transition: all 200ms ease-in-out; + } + :hover { + color: #ff8d00; + border-color: #ff8d00; + svg { + fill: #ff8d00; + } + } + @media (max-width: 768px) { + padding: 10px 0; + font-size: 14px; + } +`; + +const WalletInfo = () => { + const ContextValue = React.useContext(WalletContext); + const { wallet, fiatPrice, balances, txHistory, apiError } = ContextValue; + const [address, setAddress] = React.useState('cashAddress'); + + const hasHistory = + (txHistory && + txHistory[0] && + txHistory[0].transactions && + txHistory[0].transactions.length > 0) || + (txHistory && + txHistory[1] && + txHistory[1].transactions && + txHistory[1].transactions.length > 0); + + const handleChangeAddress = () => { + setAddress(address === 'cashAddress' ? 'slpAddress' : 'cashAddress'); + }; + + return ( + <> + {!balances.totalBalance && !apiError && !hasHistory ? ( + <> + + + 🎉 + + Congratulations on your new wallet!{' '} + + 🎉 + +
Start using the wallet immediately to receive{' '} + {currency.ticker} payments, or load it up with{' '} + {currency.ticker} to send to others +
+ 0 {currency.ticker} + + ) : ( + <> + + {balances.totalBalance} {currency.ticker} + + {fiatPrice !== null && !isNaN(balances.totalBalance) && ( + + ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} + USD + + )} + + )} + {apiError && ( + <> +

+ An error occured on our end. +

Re-establishing connection... +

+ + + )} + + + + + handleChangeAddress()} + className={ + address !== 'cashAddress' ? 'nonactiveBtn' : null + } + > + {currency.ticker} + + handleChangeAddress()} + className={ + address === 'cashAddress' ? 'nonactiveBtn' : 'slpActive' + } + > + {currency.tokenTicker} + + + {balances.totalBalance ? ( + <> + Send + + View Transactions + + + ) : null} + + ); +}; + +const Wallet = () => { + const ContextValue = React.useContext(WalletContext); + const { wallet, tokens, loading } = ContextValue; + + return ( + <> + {loading && ( + + + + )} + {!loading && wallet.Path245 && } + {!loading && wallet.Path245 && tokens && tokens.length > 0 && ( + + )} + {!loading && !wallet.Path245 ? : null} + + ); +}; + +export default Wallet; diff --git a/web/cashtab/src/components/Wallet/__mocks__/walletAndBalancesMock.js b/web/cashtab/src/components/Wallet/__mocks__/walletAndBalancesMock.js new file mode 100644 index 000000000..8adf22634 --- /dev/null +++ b/web/cashtab/src/components/Wallet/__mocks__/walletAndBalancesMock.js @@ -0,0 +1,83 @@ +import BigNumber from 'bignumber.js'; + +export const walletWithBalancesMock = { + wallet: { + Path245: { + slpAddress: + 'simpleledger:qryupy05jz7tlhtda2xth8vyvdqksyqh5cp5kf5vth', + }, + Path145: { + cashAddress: + 'bitcoincash:qrn4er57cvr5fulyl4hduef6czgu6u2yu522f4gv6f', + }, + }, + balances: { + totalBalance: 0.000546, + }, +}; + +export const walletWithoutBalancesMock = { + wallet: { + Path245: { + slpAddress: + 'simpleledger:qryupy05jz7tlhtda2xth8vyvdqksyqh5cp5kf5vth', + }, + Path145: { + cashAddress: + 'bitcoincash:qrn4er57cvr5fulyl4hduef6czgu6u2yu522f4gv6f', + }, + }, + tokens: [], + balances: { + totalBalance: 0, + }, +}; + +export const walletWithBalancesAndTokens = { + wallet: { + Path245: { + slpAddress: + 'simpleledger:qryupy05jz7tlhtda2xth8vyvdqksyqh5cp5kf5vth', + }, + Path145: { + cashAddress: + 'bitcoincash:qrn4er57cvr5fulyl4hduef6czgu6u2yu522f4gv6f', + }, + }, + balances: { + totalBalances: 0.0000546, + }, + tokens: [ + { + info: { + height: 659843, + tx_hash: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + tx_pos: 2, + value: 546, + address: + 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + satoshis: 546, + txid: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + vout: 2, + utxoType: 'token', + transactionType: 'send', + tokenId: + '2aef6e63edfded1a299e78b529286deea2a6dd5299b6911778c25632d78a9479', + tokenTicker: 'PTC', + tokenName: 'pitico', + tokenDocumentUrl: 'bitcoinabc.org', + tokenDocumentHash: '', + decimals: 5, + tokenType: 1, + tokenQty: 99.35, + isValid: true, + }, + tokenId: + '2aef6e63edfded1a299e78b529286deea2a6dd5299b6911778c25632d78a9479', + balance: new BigNumber(99), + hasBaton: false, + }, + ], +}; diff --git a/web/cashtab/src/components/Wallet/__tests__/Wallet.test.js b/web/cashtab/src/components/Wallet/__tests__/Wallet.test.js new file mode 100644 index 000000000..f1c6189b3 --- /dev/null +++ b/web/cashtab/src/components/Wallet/__tests__/Wallet.test.js @@ -0,0 +1,76 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import Wallet from '../Wallet'; +import { + walletWithBalancesAndTokens, + walletWithBalancesMock, + walletWithoutBalancesMock, +} from '../__mocks__/walletAndBalancesMock'; +import { BrowserRouter as Router } from 'react-router-dom'; + +let realUseContext; +let useContextMock; + +beforeEach(() => { + realUseContext = React.useContext; + useContextMock = React.useContext = jest.fn(); +}); + +afterEach(() => { + React.useContext = realUseContext; +}); + +test('Wallet without BCH balance', () => { + useContextMock.mockReturnValue(walletWithoutBalancesMock); + const component = renderer.create( + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with BCH balances', () => { + useContextMock.mockReturnValue(walletWithBalancesMock); + const component = renderer.create( + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with BCH balances and tokens', () => { + useContextMock.mockReturnValue(walletWithBalancesAndTokens); + const component = renderer.create( + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Without wallet defined', () => { + useContextMock.mockReturnValue({ + wallet: {}, + balances: { totalBalance: 0 }, + }); + const component = renderer.create( + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); +// test("Wallet with BCH balances and tokens", () => { +// useContextMock.mockReturnValue({ wallet: { mnemonic: 'test mnemonic' } }); +// const component = renderer.create( +// , +// ); +// let tree = component.toJSON(); +// expect(tree).toMatchSnapshot(); +// }); diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap new file mode 100644 index 000000000..70eaa357b --- /dev/null +++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap @@ -0,0 +1,485 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Wallet with BCH balances 1`] = ` +Array [ +
+ 0.000546 + + BCHA +
, +
+ $ + NaN + + USD +
, +
+
+ Copied +
+ + + + + +
+ + + qrn4er + + 57cvr5fulyl4hduef6czgu6u2yu522 + + f4gv6f + +
+
, +
+
+ BCHA +
+
+ SLPA +
+
, + + Send + , + + + + + View Transactions + , +] +`; + +exports[`Wallet with BCH balances and tokens 1`] = ` +Array [ +
+ + 🎉 + + Congratulations on your new wallet! + + + 🎉 + +
+ Start using the wallet immediately to receive + + BCHA + payments, or load it up with + + BCHA + to send to others +
, +
+ 0 + BCHA +
, +
+
+ Copied +
+ + + + + +
+ + + qrn4er + + 57cvr5fulyl4hduef6czgu6u2yu522 + + f4gv6f + +
+
, +
+
+ BCHA +
+
+ SLPA +
+
, +
+

+ SLPA + Tokens +

+ +
+
+ identicon of tokenId 2aef6e63edfded1a299e78b529286deea2a6dd5299b6911778c25632d78a9479 +
+
+ 99 + + + PTC + +
+
+
+
, +] +`; + +exports[`Wallet without BCH balance 1`] = ` +Array [ +
+ + 🎉 + + Congratulations on your new wallet! + + + 🎉 + +
+ Start using the wallet immediately to receive + + BCHA + payments, or load it up with + + BCHA + to send to others +
, +
+ 0 + BCHA +
, +
+
+ Copied +
+ + + + + +
+ + + qrn4er + + 57cvr5fulyl4hduef6czgu6u2yu522 + + f4gv6f + +
+
, +
+
+ BCHA +
+
+ SLPA +
+
, +] +`; + +exports[`Without wallet defined 1`] = ` +Array [ +

+ Welcome to CashTab! CashTab is an open source, non-custodial web wallet for + Bitcoin ABC + . +
+
+ Web wallets offer user convenience, but storing large amounts of money on a web wallet is not recommended. +
+
+ Create a new wallet below to get started, or import an existing wallet using a seed phrase. +

, + , + , +] +`; diff --git a/web/cashtab/src/components/__tests__/NotFound.test.js b/web/cashtab/src/components/__tests__/NotFound.test.js new file mode 100644 index 000000000..6e672cfeb --- /dev/null +++ b/web/cashtab/src/components/__tests__/NotFound.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import NotFound from '../NotFound'; + +test('Render NotFound component', () => { + const component = renderer.create(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/__tests__/__snapshots__/NotFound.test.js.snap b/web/cashtab/src/components/__tests__/__snapshots__/NotFound.test.js.snap new file mode 100644 index 000000000..ef598f11e --- /dev/null +++ b/web/cashtab/src/components/__tests__/__snapshots__/NotFound.test.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render NotFound component 1`] = ` +
+
+

+ Page not found +

+
+
+`; diff --git a/web/cashtab/src/hooks/__mocks__/mockReturnGetSlpBalancesAndUtxos.js b/web/cashtab/src/hooks/__mocks__/mockReturnGetSlpBalancesAndUtxos.js new file mode 100644 index 000000000..a2c3fae65 --- /dev/null +++ b/web/cashtab/src/hooks/__mocks__/mockReturnGetSlpBalancesAndUtxos.js @@ -0,0 +1,133 @@ +import BigNumber from 'bignumber.js'; + +export default { + tokens: [ + { + info: { + height: 659837, + tx_hash: + 'bb055d815e795d7a4fe41fd67288d71886678e7d1e5ba9ddd7f6daffa5f70ceb', + tx_pos: 2, + value: 546, + satoshis: 546, + txid: + 'bb055d815e795d7a4fe41fd67288d71886678e7d1e5ba9ddd7f6daffa5f70ceb', + vout: 2, + utxoType: 'token', + transactionType: 'send', + tokenId: + '009ff65ef50632111a9661f67d80025481ebc80ced239d32a92705b04d5df8cc', + tokenTicker: 'TTT', + tokenName: 'TT', + tokenDocumentUrl: 'mint.bitcoin.com', + tokenDocumentHash: '', + decimals: 1, + tokenType: 1, + tokenQty: 7, + isValid: true, + address: + 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + }, + tokenId: + '009ff65ef50632111a9661f67d80025481ebc80ced239d32a92705b04d5df8cc', + balance: new BigNumber(7), + hasBaton: false, + }, + { + info: { + height: 659843, + tx_hash: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + tx_pos: 2, + value: 546, + satoshis: 546, + txid: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + vout: 2, + utxoType: 'token', + transactionType: 'send', + tokenId: + '2aef6e63edfded1a299e78b529286deea2a6dd5299b6911778c25632d78a9479', + tokenTicker: 'PTC', + tokenName: 'pitico', + tokenDocumentUrl: 'mint.bitcoin.com', + tokenDocumentHash: '', + decimals: 5, + tokenType: 1, + tokenQty: 99.35, + isValid: true, + address: + 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + }, + tokenId: + '2aef6e63edfded1a299e78b529286deea2a6dd5299b6911778c25632d78a9479', + balance: new BigNumber(99.35), + hasBaton: false, + }, + ], + nonSlpUtxos: [ + { + height: 660544, + tx_hash: + '5ebb5272b6f875b65f41f2cfca031e6f07f9cf519ee7577f0f63cb79aa86fd0a', + tx_pos: 1, + value: 2196, + satoshis: 2196, + txid: + '5ebb5272b6f875b65f41f2cfca031e6f07f9cf519ee7577f0f63cb79aa86fd0a', + vout: 1, + isValid: false, + address: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', + }, + ], + slpUtxos: [ + { + height: 659837, + tx_hash: + 'bb055d815e795d7a4fe41fd67288d71886678e7d1e5ba9ddd7f6daffa5f70ceb', + tx_pos: 2, + value: 546, + satoshis: 546, + txid: + 'bb055d815e795d7a4fe41fd67288d71886678e7d1e5ba9ddd7f6daffa5f70ceb', + vout: 2, + utxoType: 'token', + transactionType: 'send', + tokenId: + '009ff65ef50632111a9661f67d80025481ebc80ced239d32a92705b04d5df8cc', + tokenTicker: 'TTT', + tokenName: 'TT', + tokenDocumentUrl: 'mint.bitcoin.com', + tokenDocumentHash: '', + decimals: 1, + tokenType: 1, + tokenQty: 7, + isValid: true, + address: 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + }, + { + height: 659843, + tx_hash: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + tx_pos: 2, + value: 546, + satoshis: 546, + txid: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + vout: 2, + utxoType: 'token', + transactionType: 'send', + tokenId: + '2aef6e63edfded1a299e78b529286deea2a6dd5299b6911778c25632d78a9479', + tokenTicker: 'PTC', + tokenName: 'pitico', + tokenDocumentUrl: 'mint.bitcoin.com', + tokenDocumentHash: '', + decimals: 5, + tokenType: 1, + tokenQty: 99.35, + isValid: true, + address: 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + }, + ], +}; diff --git a/web/cashtab/src/hooks/__mocks__/mockReturnGetUtxos.js b/web/cashtab/src/hooks/__mocks__/mockReturnGetUtxos.js new file mode 100644 index 000000000..604ce731f --- /dev/null +++ b/web/cashtab/src/hooks/__mocks__/mockReturnGetUtxos.js @@ -0,0 +1,33 @@ +export default [ + { + utxos: [ + { + height: 659837, + tx_hash: + 'bb055d815e795d7a4fe41fd67288d71886678e7d1e5ba9ddd7f6daffa5f70ceb', + tx_pos: 2, + value: 546, + }, + { + height: 659843, + tx_hash: + '88b7dac07cb30566a6264f330bedda690c8dff151c2307692c79e13dc59ca2ba', + tx_pos: 2, + value: 546, + }, + ], + address: 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + }, + { + utxos: [ + { + height: 660544, + tx_hash: + '5ebb5272b6f875b65f41f2cfca031e6f07f9cf519ee7577f0f63cb79aa86fd0a', + tx_pos: 1, + value: 2196, + }, + ], + address: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', + }, +]; diff --git a/web/cashtab/src/hooks/__mocks__/sendBCH.js b/web/cashtab/src/hooks/__mocks__/sendBCH.js new file mode 100644 index 000000000..aa1f73c38 --- /dev/null +++ b/web/cashtab/src/hooks/__mocks__/sendBCH.js @@ -0,0 +1,40 @@ +export default { + utxos: [ + { + height: 0, + tx_hash: + '6e83b4bf54b5a85b6c40c4e2076a6e3945b86e4d219a931d0eb93ba1a1e3bd6f', + tx_pos: 1, + value: 131689, + address: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', + satoshis: 131689, + txid: + '6e83b4bf54b5a85b6c40c4e2076a6e3945b86e4d219a931d0eb93ba1a1e3bd6f', + vout: 1, + isValid: false, + wif: 'L3ufcMjHZ2u8v2NeyHB2pCSE5ezCk8dvR7kcLLX2B3xK5VgK9wz4', + }, + ], + wallet: { + Path145: { + cashAddress: + 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', + }, + }, + addresses: ['bitcoincash:qr2npxqwznhp7gphatcqzexeclx0hhwdxg386ez36n'], + values: ['0.00000546'], + expectedTxId: + '7a39961bbd7e27d804fb3169ef38a83234710fbc53897a4eb0c98454854a26d1', + expectedHex: [ + '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006b483045022100d52237ac2c000c0be195bb27d5488b378559cf4a7958c9a1b1ce7a6850773a2b02202e3c148450c6efdb11f199a9398332b313b54686ff98cf74b254f9ae144b0c7d4121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0222020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288ac62ff0100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', + ], + expectedHexThreeSatPerByteFee: [ + '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006b483045022100ea9661be764a02a74c8f5511becd65436bc358cbfe84e9c9be25e359747cecc102204d757b9a1175eb958cc93dc83a29c2c1fc46f95afb58674b9d677776ab7c18d94121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0222020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288ac9efd0100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', + ], + expectedHexFiveSatPerByteFee: [ + '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006a473044022048f8a04db2fb6d83de621c5d77243e14f94be62b9f97d5fb391a1587f04cee2b02204a406baa3f08256591eca9bb24434946effaec33214298cf6a41638985f6515d4121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0222020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288acdafb0100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', + ], + expectedHexEightyThreeSatPerByteFee: [ + '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006b483045022100b71662c5187dfc0df8ee0e16bb06aedae38714dbe8855abd83c96228e49fe8ca0220032313e0481141eac90e382c6f780b8bb0c44e9ceaa977eb3adee933cbfb28034121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0222020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288acbdb60100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', + ], +}; diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js new file mode 100644 index 000000000..8c26138d9 --- /dev/null +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -0,0 +1,220 @@ +/* eslint-disable no-native-reassign */ +import useBCH from '../useBCH'; +import mockReturnGetUtxos from '../__mocks__/mockReturnGetUtxos'; +import mockReturnGetSlpBalancesAndUtxos from '../__mocks__/mockReturnGetSlpBalancesAndUtxos'; +import sendBCHMock from '../__mocks__/sendBCH'; +import BCHJS from '@psf/bch-js'; // TODO: should be removed when external lib not needed anymore +import { currency } from '../../components/Common/Ticker'; +import sendBCH from '../__mocks__/sendBCH'; + +describe('useBCH hook', () => { + it('gets Rest Api Url on testnet', () => { + process = { + env: { + REACT_APP_NETWORK: `testnet`, + REACT_APP_API_TEST: `https://free-test.fullstack.cash/v3/`, + REACT_APP_API: `https://free-main.fullstack.cash/v3/`, + }, + }; + const { getRestUrl } = useBCH(); + const expectedApiUrl = `https://free-test.fullstack.cash/v3/`; + expect(getRestUrl()).toBe(expectedApiUrl); + }); + + it('gets Rest Api Url on mainnet', () => { + process = { + env: { + REACT_APP_NETWORK: `mainnet`, + REACT_APP_API_TEST: `https://free-test.fullstack.cash/v3/`, + REACT_APP_API: `https://free-main.fullstack.cash/v3/`, + }, + }; + const { getRestUrl } = useBCH(); + const expectedApiUrl = `https://free-main.fullstack.cash/v3/`; + expect(getRestUrl()).toBe(expectedApiUrl); + }); + + it('calculates fee correctly for 2 P2PKH outputs', () => { + const { calcFee } = useBCH(); + const BCH = new BCHJS(); + const utxosMock = [{}, {}]; + // For 1.01 sat/byte fee + let expectedTxFee = 378; + if (currency.defaultFee === 3.01) { + expectedTxFee = 1126; + } else if (currency.defaultFee === 5.01) { + expectedTxFee = 1874; + } else if ((currency.defaultFee = 83.3)) { + expectedTxFee = 31155; + } + expect(calcFee(BCH, utxosMock)).toBe(expectedTxFee); + }); + + it('gets utxos', async () => { + const { getUtxos } = useBCH(); + const BCH = new BCHJS(); + + const addresses = [ + 'bitcoincash:qphazxf3vhe4qchvzz2pjempdhplaxcj957xqq8mg2', + 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', + ]; + + const result = await getUtxos(BCH, addresses); + expect(result).toStrictEqual(mockReturnGetUtxos); + }); + + it('gets SLP and BCH balances and utxos', async () => { + const { getSlpBalancesAndUtxos } = useBCH(); + const BCH = new BCHJS(); + const utxos = mockReturnGetUtxos; + + const result = await getSlpBalancesAndUtxos(BCH, utxos); + + expect(result).toStrictEqual(mockReturnGetSlpBalancesAndUtxos); + }); + + it('sends BCH correctly', async () => { + const { sendBch } = useBCH(); + const BCH = new BCHJS(); + const { + expectedTxId, + expectedHex, + utxos, + wallet, + addresses, + values, + } = sendBCHMock; + let expectedHexByFee = expectedHex; + if (currency.defaultFee === 3.01) { + expectedHexByFee = sendBCHMock.expectedHexThreeSatPerByteFee; + } else if (currency.defaultFee === 5.01) { + expectedHexByFee = sendBCHMock.expectedHexFiveSatPerByteFee; + } else if (currency.defaultFee === 83.3) { + expectedHexByFee = sendBCHMock.expectedHexEightyThreeSatPerByteFee; + } + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + expect(await sendBch(BCH, wallet, utxos, { addresses, values })).toBe( + `${currency.blockExplorerUrl}/tx/${expectedTxId}`, + ); + expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( + expectedHexByFee, + ); + }); + + it('sends BCH correctly with callback', async () => { + const { sendBch } = useBCH(); + const BCH = new BCHJS(); + const callback = jest.fn(); + const { + expectedTxId, + expectedHex, + utxos, + wallet, + addresses, + values, + } = sendBCHMock; + let expectedHexByFee = expectedHex; + if (currency.defaultFee === 3.01) { + expectedHexByFee = sendBCHMock.expectedHexThreeSatPerByteFee; + } else if (currency.defaultFee === 5.01) { + expectedHexByFee = sendBCHMock.expectedHexFiveSatPerByteFee; + } else if (currency.defaultFee === 83.3) { + expectedHexByFee = sendBCHMock.expectedHexEightyThreeSatPerByteFee; + } + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + expect( + await sendBch(BCH, wallet, utxos, { addresses, values }, callback), + ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); + expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( + expectedHexByFee, + ); + expect(callback).toHaveBeenCalledWith(expectedTxId); + }); + + it('sends BCH with less BCH available on balance', async () => { + const { sendBch } = useBCH(); + const BCH = new BCHJS(); + const { + expectedTxId, + expectedHex, + utxos, + wallet, + addresses, + } = sendBCHMock; + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + const failedSendBch = sendBch(BCH, wallet, utxos, { + addresses, + values: [1], + }); + expect(failedSendBch).rejects.toThrow(new Error('Insufficient funds')); + const nullValuesSendBch = await sendBch(BCH, wallet, utxos, { + addresses, + values: null, + }); + expect(nullValuesSendBch).toBe(null); + }); + + it('receives errors from the network and parses it', async () => { + const { sendBch } = useBCH(); + const BCH = new BCHJS(); + const { values, utxos, wallet, addresses } = sendBCHMock; + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + throw new Error('insufficient priority (code 66)'); + }); + const insufficientPriority = sendBch(BCH, wallet, utxos, { + addresses, + values, + }); + await expect(insufficientPriority).rejects.toThrow( + new Error('insufficient priority (code 66)'), + ); + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + throw new Error('txn-mempool-conflict (code 18)'); + }); + const txnMempoolConflict = sendBch(BCH, wallet, utxos, { + addresses, + values, + }); + await expect(txnMempoolConflict).rejects.toThrow( + new Error('txn-mempool-conflict (code 18)'), + ); + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + throw new Error('Network Error'); + }); + const networkError = sendBch(BCH, wallet, utxos, { addresses, values }); + await expect(networkError).rejects.toThrow(new Error('Network Error')); + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + const err = new Error( + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', + ); + throw err; + }); + + const tooManyAncestorsMempool = sendBch(BCH, wallet, utxos, { + addresses, + values, + }); + await expect(tooManyAncestorsMempool).rejects.toThrow( + new Error( + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', + ), + ); + }); +}); diff --git a/web/cashtab/src/hooks/useAsyncTimeout.js b/web/cashtab/src/hooks/useAsyncTimeout.js new file mode 100644 index 000000000..0592ea913 --- /dev/null +++ b/web/cashtab/src/hooks/useAsyncTimeout.js @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; + +const useAsyncTimeout = (callback, delay) => { + const savedCallback = useRef(callback); + + useEffect(() => { + savedCallback.current = callback; + }); + + useEffect(() => { + let id = null; + const tick = () => { + const promise = savedCallback.current(); + + if (promise instanceof Promise) { + promise.then(() => { + id = setTimeout(tick, delay); + }); + } else { + id = setTimeout(tick, delay); + } + }; + + if (id !== null) { + id = setTimeout(tick, delay); + return () => clearTimeout(id); + } else { + tick(); + return; + } + }, [delay]); +}; + +export default useAsyncTimeout; diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js new file mode 100644 index 000000000..f390210f3 --- /dev/null +++ b/web/cashtab/src/hooks/useBCH.js @@ -0,0 +1,701 @@ +import BigNumber from 'bignumber.js'; +import { currency } from '../components/Common/Ticker'; + +export default function useBCH() { + const DUST = 0.000005; + const SEND_BCH_ERRORS = { + INSUFICIENT_FUNDS: 0, + NETWORK_ERROR: 1, + INSUFFICIENT_PRIORITY: 66, // ~insufficient fee + DOUBLE_SPENDING: 18, + MAX_UNCONFIRMED_TXS: 64, + }; + + const getRestUrl = () => + process.env.REACT_APP_NETWORK === `mainnet` + ? process.env.REACT_APP_API + : process.env.REACT_APP_API_TEST; + + const getTxHistory = async (BCH, addresses) => { + let txHistoryResponse; + try { + //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); + //console.log(addresses); + txHistoryResponse = await BCH.Electrumx.transactions(addresses); + //console.log(`BCH.Electrumx.transactions(addresses) succeeded`); + //console.log(`txHistoryResponse`, txHistoryResponse); + if (txHistoryResponse.success && txHistoryResponse.transactions) { + return txHistoryResponse.transactions; + } else { + // eslint-disable-next-line no-throw-literal + throw new Error('Error in getTxHistory'); + } + } catch (err) { + console.log(`Error in BCH.Electrumx.transactions(addresses):`); + console.log(err); + return err; + } + }; + + // Split out the BCH.Electrumx.utxo(addresses) call from the getSlpBalancesandUtxos function + // If utxo set has not changed, you do not need to hydrate the utxo set + // This drastically reduces calls to the API + const getUtxos = async (BCH, addresses) => { + let utxosResponse; + try { + //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); + //console.log(addresses); + utxosResponse = await BCH.Electrumx.utxo(addresses); + //console.log(`BCH.Electrumx.utxo(addresses) succeeded`); + //console.log(`utxosResponse`, utxosResponse); + return utxosResponse.utxos; + } catch (err) { + console.log(`Error in BCH.Electrumx.utxo(addresses):`); + return err; + } + }; + + const getSlpBalancesAndUtxos = async (BCH, utxos) => { + let hydratedUtxoDetails; + + try { + hydratedUtxoDetails = await BCH.SLP.Utils.hydrateUtxos(utxos); + //console.log(`hydratedUtxoDetails`, hydratedUtxoDetails); + } catch (err) { + console.log( + `Error in BCH.SLP.Utils.hydrateUtxos(utxosResponse.utxos)`, + ); + console.log(err); + } + const hydratedUtxos = []; + for (let i = 0; i < hydratedUtxoDetails.slpUtxos.length; i += 1) { + const hydratedUtxosAtAddress = hydratedUtxoDetails.slpUtxos[i]; + for (let j = 0; j < hydratedUtxosAtAddress.utxos.length; j += 1) { + const hydratedUtxo = hydratedUtxosAtAddress.utxos[j]; + hydratedUtxo.address = hydratedUtxosAtAddress.address; + hydratedUtxos.push(hydratedUtxo); + } + } + + //console.log(`hydratedUtxos`, hydratedUtxos); + + // WARNING + // If you hit rate limits, your above utxos object will come back with `isValid` as null, but otherwise ok + // You need to throw an error before setting nonSlpUtxos and slpUtxos in this case + const nullUtxos = hydratedUtxos.filter(utxo => utxo.isValid === null); + //console.log(`nullUtxos`, nullUtxos); + if (nullUtxos.length > 0) { + console.log( + `${nullUtxos.length} null utxos found, ignoring results`, + ); + throw new Error('Null utxos found, ignoring results'); + } + + // Prevent app from treating slpUtxos as nonSlpUtxos + // Must enforce === false as api will occasionally return utxo.isValid === null + // Do not classify utxos with 546 satoshis as nonSlpUtxos as a precaution + // Do not classify any utxos that include token information as nonSlpUtxos + const nonSlpUtxos = hydratedUtxos.filter( + utxo => + utxo.isValid === false && + utxo.satoshis !== 546 && + !utxo.tokenName, + ); + const slpUtxos = hydratedUtxos.filter(utxo => utxo.isValid); + + let tokensById = {}; + + slpUtxos.forEach(slpUtxo => { + let token = tokensById[slpUtxo.tokenId]; + + if (token) { + // Minting baton does nto have a slpUtxo.tokenQty type + + if (slpUtxo.tokenQty) { + token.balance = token.balance.plus( + new BigNumber(slpUtxo.tokenQty), + ); + } + + //token.hasBaton = slpUtxo.transactionType === "genesis"; + if (slpUtxo.utxoType && !token.hasBaton) { + token.hasBaton = slpUtxo.utxoType === 'minting-baton'; + } + + // Examples of slpUtxo + /* + Genesis transaction: + { + address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" + decimals: 9 + height: 617564 + isValid: true + satoshis: 546 + tokenDocumentHash: "" + tokenDocumentUrl: "developer.bitcoin.com" + tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + tokenName: "PiticoLaunch" + tokenTicker: "PTCL" + tokenType: 1 + tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + tx_pos: 2 + txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + utxoType: "minting-baton" + value: 546 + vout: 2 + } + + Send transaction: + { + address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" + decimals: 9 + height: 655115 + isValid: true + satoshis: 546 + tokenDocumentHash: "" + tokenDocumentUrl: "developer.bitcoin.com" + tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + tokenName: "PiticoLaunch" + tokenQty: 1.123456789 + tokenTicker: "PTCL" + tokenType: 1 + transactionType: "send" + tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" + tx_pos: 1 + txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" + utxoType: "token" + value: 546 + vout: 1 + } + */ + } else { + token = {}; + token.info = slpUtxo; + token.tokenId = slpUtxo.tokenId; + if (slpUtxo.tokenQty) { + token.balance = new BigNumber(slpUtxo.tokenQty); + } else { + token.balance = new BigNumber(0); + } + if (slpUtxo.utxoType) { + token.hasBaton = slpUtxo.utxoType === 'minting-baton'; + } else { + token.hasBaton = false; + } + + tokensById[slpUtxo.tokenId] = token; + } + }); + + const tokens = Object.values(tokensById); + // console.log(`tokens`, tokens); + return { + tokens, + nonSlpUtxos, + slpUtxos, + }; + }; + + const calcFee = ( + BCH, + utxos, + p2pkhOutputNumber = 2, + satoshisPerByte = currency.defaultFee, + ) => { + const byteCount = BCH.BitcoinCash.getByteCount( + { P2PKH: utxos.length }, + { P2PKH: p2pkhOutputNumber }, + ); + const txFee = Math.ceil(satoshisPerByte * byteCount); + return txFee; + }; + + const sendToken = async ( + BCH, + wallet, + slpBalancesAndUtxos, + { tokenId, amount, tokenReceiverAddress }, + ) => { + const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( + (previous, current) => + previous.satoshis > current.satoshis ? previous : current, + ); + // console.log(`largestBchUtxo`, largestBchUtxo); + // this is big enough? might need to combine utxos + // TODO improve utxo selection + /* + { + address: "bitcoincash:qrcl220pxeec78vnchwyh6fsdyf60uv9tcynw3u2ev" + height: 0 + isValid: false + satoshis: 1510 + tx_hash: "faef4d8bf56353702e29c22f2aace970ddbac617144456d509e23e1192b320a8" + tx_pos: 0 + txid: "faef4d8bf56353702e29c22f2aace970ddbac617144456d509e23e1192b320a8" + value: 1510 + vout: 0 + wif: "removed for git potential" + } + */ + const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); + const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter( + (utxo, index) => { + if ( + utxo && // UTXO is associated with a token. + utxo.tokenId === tokenId && // UTXO matches the token ID. + utxo.utxoType === 'token' // UTXO is not a minting baton. + ) { + return true; + } + return false; + }, + ); + + if (tokenUtxos.length === 0) { + throw new Error( + 'No token UTXOs for the specified token could be found.', + ); + } + + // BEGIN transaction construction. + + // instance of transaction builder + let transactionBuilder; + if (process.env.REACT_APP_NETWORK === 'mainnet') { + transactionBuilder = new BCH.TransactionBuilder(); + } else transactionBuilder = new BCH.TransactionBuilder('testnet'); + + const originalAmount = largestBchUtxo.value; + transactionBuilder.addInput( + largestBchUtxo.tx_hash, + largestBchUtxo.tx_pos, + ); + + let finalTokenAmountSent = new BigNumber(0); + let tokenAmountBeingSentToAddress = new BigNumber(amount); + /* + console.log(`tokenAmountBeingSentToAddress`, tokenAmountBeingSentToAddress); + console.log( + `tokenAmountBeingSentToAddress.toString()`, + tokenAmountBeingSentToAddress.toString() + ); + */ + let tokenUtxosBeingSpent = []; + for (let i = 0; i < tokenUtxos.length; i++) { + finalTokenAmountSent = finalTokenAmountSent.plus( + new BigNumber(tokenUtxos[i].tokenQty).div( + Math.pow(10, tokenUtxos[i].decimals), + ), + ); + transactionBuilder.addInput( + tokenUtxos[i].tx_hash, + tokenUtxos[i].tx_pos, + ); + tokenUtxosBeingSpent.push(tokenUtxos[i]); + if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { + break; + } + } + + // Run a test function to mock the outputs generated by BCH.SLP.TokenType1.generateSendOpReturn below + slpDebug( + tokenUtxosBeingSpent, + tokenAmountBeingSentToAddress.toString(), + ); + + // Generate the OP_RETURN code. + console.log(`Debug output`); + console.log(`tokenUtxos`, tokenUtxosBeingSpent); + console.log(`sendQty`, tokenAmountBeingSentToAddress.toString()); + const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn( + tokenUtxosBeingSpent, + tokenAmountBeingSentToAddress.toString(), + ); + + const slpData = slpSendObj.script; + + // Add OP_RETURN as first output. + transactionBuilder.addOutput(slpData, 0); + + // Send dust transaction representing tokens being sent. + transactionBuilder.addOutput( + BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress), + 546, + ); + + // Return any token change back to the sender. + if (slpSendObj.outputs > 1) { + transactionBuilder.addOutput( + BCH.SLP.Address.toLegacyAddress( + tokenUtxosBeingSpent[0].address, + ), + 546, + ); + } + + // get byte count to calculate fee. paying 1 sat + // Note: This may not be totally accurate. Just guessing on the byteCount size. + const txFee = calcFee( + BCH, + tokenUtxosBeingSpent, + 5, + 1.1 * currency.defaultFee, + ); + + // amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size + const remainder = originalAmount - txFee - 546 * 2; + if (remainder < 1) { + throw new Error('Selected UTXO does not have enough satoshis'); + } + // Last output: send the BCH change back to the wallet. + transactionBuilder.addOutput( + BCH.Address.toLegacyAddress(largestBchUtxo.address), + remainder, + ); + + // Sign the transaction with the private key for the BCH UTXO paying the fees. + let redeemScript; + transactionBuilder.sign( + 0, + bchECPair, + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + originalAmount, + ); + + // Sign each token UTXO being consumed. + for (let i = 0; i < tokenUtxosBeingSpent.length; i++) { + const thisUtxo = tokenUtxosBeingSpent[i]; + const accounts = [wallet.Path245, wallet.Path145]; + const utxoEcPair = BCH.ECPair.fromWIF( + accounts + .filter(acc => acc.cashAddress === thisUtxo.address) + .pop().fundingWif, + ); + + transactionBuilder.sign( + 1 + i, + utxoEcPair, + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + thisUtxo.value, + ); + } + + // build tx + const tx = transactionBuilder.build(); + + // output rawhex + const hex = tx.toHex(); + // console.log(`Transaction raw hex: `, hex); + + // END transaction construction. + + // Broadcast transaction to the network + + const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); + if (txidStr && txidStr[0]) { + console.log(`${currency.tokenTicker} txid`, txidStr[0]); + } + + let link; + if (process.env.REACT_APP_NETWORK === `mainnet`) { + link = `${currency.blockExplorerUrl}/tx/${txidStr}`; + } else { + link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; + } + //console.log(`link`, link); + + return link; + }; + + const slpDebug = (tokenUtxos, sendQty) => { + console.log(`slpDebug test called with`); + console.log(`tokenUtxos`, tokenUtxos); + console.log(`sendQty`, sendQty); + try { + //const tokenId = tokenUtxos[0].tokenId; + const decimals = tokenUtxos[0].decimals; + + // Joey patch to do + // totalTokens must be a big number accounting for decimals + // sendQty must be the same + /* From slp-sdk + + amount = new BigNumber(amount).times(10 ** tokenDecimals) // Don't forget to account for token precision + + + This is analagous to sendQty here + */ + const sendQtyBig = new BigNumber(sendQty).times(10 ** decimals); + + // Calculate the total amount of tokens owned by the wallet. + //let totalTokens = 0; + //for (let i = 0; i < tokenUtxos.length; i++) totalTokens += tokenUtxos[i].tokenQty; + + // Calculate total amount of tokens using Big Number throughout + /* + let totalTokens = new BigNumber(0); + for (let i = 0; i < tokenUtxos.length; i++) { + console.log(`tokenQty normal`, tokenUtxos[i].tokenQty); + const thisTokenQty = new BigNumber(tokenUtxos[i].tokenQty); + totalTokens.plus(thisTokenQty); + } + totalTokens.times(10 ** decimals); + */ + let totalTokens = tokenUtxos.reduce((tot, txo) => { + return tot.plus( + new BigNumber(txo.tokenQty).times(10 ** decimals), + ); + }, new BigNumber(0)); + + console.log(`totalTokens`, totalTokens); + //test + //totalTokens = new BigNumber(totalTokens).times(10 ** decimals); + + console.log(`sendQtyBig`, sendQtyBig); + const change = totalTokens.minus(sendQtyBig); + console.log(`change`, change); + + //let script; + //let outputs = 1; + + // The normal case, when there is token change to return to sender. + if (change > 0) { + //outputs = 2; + + // Convert the send quantity to the format expected by slp-mdm. + + //let baseQty = new BigNumber(sendQty).times(10 ** decimals); + // Update: you've done this earlier, so don't do it now + let baseQty = sendQtyBig.toString(); + console.log(`baseQty: `, baseQty); + + // Convert the change quantity to the format expected by slp-mdm. + //let baseChange = new BigNumber(change).times(10 ** decimals); + // Update: you've done this earlier, so don't do it now + let baseChange = change.toString(); + console.log(`baseChange: `, baseChange); + + const outputQty = new BigNumber(baseChange).plus( + new BigNumber(baseQty), + ); + const inputQty = new BigNumber(totalTokens); + console.log( + `new BigNumber(baseChange)`, + new BigNumber(baseChange), + ); + console.log(`new BigNumber(baseQty)`, new BigNumber(baseQty)); + console.log(`outputQty:`, outputQty); + console.log(`inputQty:`, inputQty); + console.log( + `outputQty.minus(inputQty).toString():`, + outputQty.minus(inputQty).toString(), + ); + console.log( + `outputQty.minus(inputQty).toString():`, + outputQty.minus(inputQty).toString() === '0', + ); + + const tokenOutputDelta = + outputQty.minus(inputQty).toString() !== '0'; + if (tokenOutputDelta) + console.log( + 'Token transaction inputs do not match outputs, cannot send transaction', + ); + // Generate the OP_RETURN as a Buffer. + /* + script = slpMdm.TokenType1.send(tokenId, [ + new slpMdm.BN(baseQty), + new slpMdm.BN(baseChange) + ]); + */ + // + + // Corner case, when there is no token change to send back. + } else { + console.log(`No change case:`); + let baseQty = sendQtyBig.toString(); + console.log(`baseQty: `, baseQty); + + // Check for potential burns + const noChangeOutputQty = new BigNumber(baseQty); + const noChangeInputQty = new BigNumber(totalTokens); + console.log(`noChangeOutputQty`, noChangeOutputQty); + console.log(`noChangeInputQty`, noChangeInputQty); + + const tokenSingleOutputError = + noChangeOutputQty.minus(noChangeInputQty).toString() !== + '0'; + if (tokenSingleOutputError) + console.log( + 'Token transaction inputs do not match outputs, cannot send transaction', + ); + + // Generate the OP_RETURN as a Buffer. + //script = slpMdm.TokenType1.send(tokenId, [new slpMdm.BN(baseQty)]); + } + } catch (err) { + console.log(`Error in generateSendOpReturn()`); + throw err; + } + }; + + const sendBch = async ( + BCH, + wallet, + utxos, + { addresses, values, encodedOpReturn }, + callbackTxId, + ) => { + // Note: callbackTxId is a callback function that accepts a txid as its only parameter + /* Debug logs + console.log(`sendBch called with`); + console.log("BCH", BCH); + console.log("wallet", wallet); + console.log("utxos", utxos); + console.log("addresses", addresses); + console.log("values", values); + console.log("encodedOpReturn", encodedOpReturn); + console.log("callbackTxid", callbackTxId); + */ + try { + if (!values || values.length === 0) { + return null; + } + + const value = values.reduce( + (previous, current) => new BigNumber(current).plus(previous), + new BigNumber(0), + ); + const REMAINDER_ADDR = wallet.Path145.cashAddress; + const inputUtxos = []; + let transactionBuilder; + + // instance of transaction builder + if (process.env.REACT_APP_NETWORK === `mainnet`) + transactionBuilder = new BCH.TransactionBuilder(); + else transactionBuilder = new BCH.TransactionBuilder('testnet'); + + const satoshisToSend = BCH.BitcoinCash.toSatoshi(value.toFixed(8)); + let originalAmount = new BigNumber(0); + let txFee = 0; + for (let i = 0; i < utxos.length; i++) { + const utxo = utxos[i]; + originalAmount = originalAmount.plus(utxo.satoshis); + const vout = utxo.vout; + const txid = utxo.txid; + // add input with txid and index of vout + transactionBuilder.addInput(txid, vout); + + inputUtxos.push(utxo); + txFee = encodedOpReturn + ? calcFee(BCH, inputUtxos, addresses.length + 2) + : calcFee(BCH, inputUtxos, addresses.length + 1); + + if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { + break; + } + } + + // amount to send back to the remainder address. + const remainder = Math.floor( + originalAmount.minus(satoshisToSend).minus(txFee), + ); + if (remainder < 0) { + const error = new Error(`Insufficient funds`); + error.code = SEND_BCH_ERRORS.INSUFICIENT_FUNDS; + throw error; + } + + if (encodedOpReturn) { + transactionBuilder.addOutput(encodedOpReturn, 0); + } + + // add output w/ address and amount to send + for (let i = 0; i < addresses.length; i++) { + const address = addresses[i]; + transactionBuilder.addOutput( + BCH.Address.toCashAddress(address), + BCH.BitcoinCash.toSatoshi(Number(values[i]).toFixed(8)), + ); + } + + if (remainder >= BCH.BitcoinCash.toSatoshi(DUST)) { + transactionBuilder.addOutput(REMAINDER_ADDR, remainder); + } + + // Sign the transactions with the HD node. + for (let i = 0; i < inputUtxos.length; i++) { + const utxo = inputUtxos[i]; + transactionBuilder.sign( + i, + BCH.ECPair.fromWIF(utxo.wif), + undefined, + transactionBuilder.hashTypes.SIGHASH_ALL, + utxo.satoshis, + ); + } + + // build tx + const tx = transactionBuilder.build(); + // output rawhex + const hex = tx.toHex(); + + // Broadcast transaction to the network + const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); + + if (txidStr && txidStr[0]) { + console.log(`${currency.ticker} txid`, txidStr[0]); + } + let link; + + if (callbackTxId) { + callbackTxId(txidStr); + } + if (process.env.REACT_APP_NETWORK === `mainnet`) { + link = `${currency.blockExplorerUrl}/tx/${txidStr}`; + } else { + link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; + } + //console.log(`link`, link); + + return link; + } catch (err) { + if (err.error === 'insufficient priority (code 66)') { + err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; + } else if (err.error === 'txn-mempool-conflict (code 18)') { + err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; + } else if (err.error === 'Network Error') { + err.code = SEND_BCH_ERRORS.NETWORK_ERROR; + } else if ( + err.error === + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' + ) { + err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; + } + console.log(`error: `, err); + throw err; + } + }; + + const getBCH = (fromWindowObject = true) => { + if (fromWindowObject && window.SlpWallet) { + const SlpWallet = new window.SlpWallet('', { + restURL: getRestUrl(), + }); + return SlpWallet.bchjs; + } + }; + + return { + getBCH, + calcFee, + getUtxos, + getSlpBalancesAndUtxos, + getTxHistory, + getRestUrl, + sendBch, + sendToken, + }; +} diff --git a/web/cashtab/src/hooks/useImage.js b/web/cashtab/src/hooks/useImage.js new file mode 100644 index 000000000..ce33b6f13 --- /dev/null +++ b/web/cashtab/src/hooks/useImage.js @@ -0,0 +1,207 @@ +const useImage = () => { + const createImage = url => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', error => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + + const getResizedImg = async (imageSrc, callback, fileName) => { + const image = await createImage(imageSrc); + + const width = 128; + const height = 128; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(image, 0, 0, width, height); + if (!HTMLCanvasElement.prototype.toBlob) { + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + value: function (callback, type, quality) { + var dataURL = this.toDataURL(type, quality).split(',')[1]; + setTimeout(function () { + var binStr = atob(dataURL), + len = binStr.length, + arr = new Uint8Array(len); + for (var i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); + } + callback( + new Blob([arr], { type: type || 'image/png' }), + ); + }); + }, + }); + } + + return new Promise(resolve => { + ctx.canvas.toBlob( + blob => { + const file = new File([blob], fileName, { + type: 'image/png', + }); + const resultReader = new FileReader(); + + resultReader.readAsDataURL(file); + + resultReader.addEventListener('load', () => + callback({ file, url: resultReader.result }), + ); + resolve(); + }, + 'image/png', + 1, + ); + }); + }; + + const getRoundImg = async (imageSrc, fileName) => { + const image = await createImage(imageSrc); + console.log('image :', image); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(image, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.beginPath(); + ctx.arc( + image.width / 2, + image.height / 2, + image.height / 2, + 0, + Math.PI * 2, + ); + ctx.closePath(); + ctx.fill(); + if (!HTMLCanvasElement.prototype.toBlob) { + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + value: function (callback, type, quality) { + var dataURL = this.toDataURL(type, quality).split(',')[1]; + setTimeout(function () { + var binStr = atob(dataURL), + len = binStr.length, + arr = new Uint8Array(len); + for (var i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); + } + callback( + new Blob([arr], { type: type || 'image/png' }), + ); + }); + }, + }); + } + return new Promise(resolve => { + ctx.canvas.toBlob( + blob => { + const file = new File([blob], fileName, { + type: 'image/png', + }); + const resultReader = new FileReader(); + + resultReader.readAsDataURL(file); + + resultReader.addEventListener('load', () => + resolve({ file, url: resultReader.result }), + ); + }, + 'image/png', + 1, + ); + }); + }; + + const getRadianAngle = degreeValue => { + return (degreeValue * Math.PI) / 180; + }; + + const getCroppedImg = async ( + imageSrc, + pixelCrop, + rotation = 0, + fileName, + ) => { + const image = await createImage(imageSrc); + console.log('image :', image); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + const maxSize = Math.max(image.width, image.height); + const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)); + + canvas.width = safeArea; + canvas.height = safeArea; + + ctx.translate(safeArea / 2, safeArea / 2); + ctx.rotate(getRadianAngle(rotation)); + ctx.translate(-safeArea / 2, -safeArea / 2); + + ctx.drawImage( + image, + safeArea / 2 - image.width * 0.5, + safeArea / 2 - image.height * 0.5, + ); + const data = ctx.getImageData(0, 0, safeArea, safeArea); + + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + ctx.putImageData( + data, + 0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x, + 0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y, + ); + + if (!HTMLCanvasElement.prototype.toBlob) { + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + value: function (callback, type, quality) { + var dataURL = this.toDataURL(type, quality).split(',')[1]; + setTimeout(function () { + var binStr = atob(dataURL), + len = binStr.length, + arr = new Uint8Array(len); + for (var i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); + } + callback( + new Blob([arr], { type: type || 'image/png' }), + ); + }); + }, + }); + } + return new Promise(resolve => { + ctx.canvas.toBlob( + blob => { + const file = new File([blob], fileName, { + type: 'image/png', + }); + const resultReader = new FileReader(); + + resultReader.readAsDataURL(file); + + resultReader.addEventListener('load', () => + resolve({ file, url: resultReader.result }), + ); + }, + 'image/png', + 1, + ); + }); + }; + + return { + getCroppedImg, + getRadianAngle, + getRoundImg, + getResizedImg, + }; +}; + +export default useImage; diff --git a/web/cashtab/src/hooks/useInnerScroll.js b/web/cashtab/src/hooks/useInnerScroll.js new file mode 100644 index 000000000..48aea275d --- /dev/null +++ b/web/cashtab/src/hooks/useInnerScroll.js @@ -0,0 +1,7 @@ +import { useEffect } from 'react'; + +export const useInnerScroll = () => + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => (document.body.style.overflow = ''); + }, []); diff --git a/web/cashtab/src/hooks/useInterval.js b/web/cashtab/src/hooks/useInterval.js new file mode 100644 index 000000000..c8608cf60 --- /dev/null +++ b/web/cashtab/src/hooks/useInterval.js @@ -0,0 +1,20 @@ +import { useRef, useEffect } from 'react'; + +const useInterval = (callback, delay) => { + const savedCallback = useRef(); + + useEffect(() => { + savedCallback.current = callback; + }); + + useEffect(() => { + function tick() { + savedCallback.current(); + } + + let id = setInterval(tick, delay); + return () => clearInterval(id); + }, [delay]); +}; + +export default useInterval; diff --git a/web/cashtab/src/hooks/usePrevious.js b/web/cashtab/src/hooks/usePrevious.js new file mode 100644 index 000000000..ddcad7303 --- /dev/null +++ b/web/cashtab/src/hooks/usePrevious.js @@ -0,0 +1,17 @@ +import { useRef, useEffect } from 'react'; + +export const usePrevious = value => { + // The ref object is a generic container whose current property is mutable ... + // ... and can hold any value, similar to an instance property on a class + const ref = useRef(); + + // Store current value in ref + useEffect(() => { + ref.current = value; + }, [value]); // Only re-run if value changes + + // Return previous value (happens before update in useEffect above) + return ref.current; +}; + +export default usePrevious; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js new file mode 100644 index 000000000..454ffbb43 --- /dev/null +++ b/web/cashtab/src/hooks/useWallet.js @@ -0,0 +1,1050 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +import React, { useState, useEffect } from 'react'; +import Paragraph from 'antd/lib/typography/Paragraph'; +import { notification } from 'antd'; +import useAsyncTimeout from './useAsyncTimeout'; +import usePrevious from './usePrevious'; +import useBCH from '../hooks/useBCH'; +import BigNumber from 'bignumber.js'; +import localforage from 'localforage'; +import { currency } from '../components/Common/Ticker'; +import _ from 'lodash'; + +const useWallet = () => { + const [wallet, setWallet] = useState(false); + const [fiatPrice, setFiatPrice] = useState(null); + const [ws, setWs] = useState(null); + const [apiError, setApiError] = useState(false); + const [walletState, setWalletState] = useState({ + balances: {}, + tokens: [], + slpBalancesAndUtxos: [], + txHistory: [], + }); + const { getBCH, getUtxos, getSlpBalancesAndUtxos, getTxHistory } = useBCH(); + const [loading, setLoading] = useState(true); + const [BCH] = useState(getBCH()); + const [utxos, setUtxos] = useState(null); + const { balances, tokens, slpBalancesAndUtxos, txHistory } = walletState; + const previousBalances = usePrevious(balances); + const previousTokens = usePrevious(tokens); + const previousWallet = usePrevious(wallet); + const previousUtxos = usePrevious(utxos); + + const normalizeSlpBalancesAndUtxos = (slpBalancesAndUtxos, wallet) => { + const Accounts = [wallet.Path245, wallet.Path145]; + slpBalancesAndUtxos.nonSlpUtxos.forEach(utxo => { + const derivatedAccount = Accounts.find( + account => account.cashAddress === utxo.address, + ); + utxo.wif = derivatedAccount.fundingWif; + }); + + return slpBalancesAndUtxos; + }; + + const normalizeBalance = slpBalancesAndUtxos => { + const totalBalanceInSatoshis = slpBalancesAndUtxos.nonSlpUtxos.reduce( + (previousBalance, utxo) => previousBalance + utxo.satoshis, + 0, + ); + return { + totalBalanceInSatoshis, + totalBalance: BCH.BitcoinCash.toBitcoinCash(totalBalanceInSatoshis), + }; + }; + + const deriveAccount = async ({ masterHDNode, path }) => { + const node = BCH.HDNode.derivePath(masterHDNode, path); + const cashAddress = BCH.HDNode.toCashAddress(node); + const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); + + return { + cashAddress, + slpAddress, + fundingWif: BCH.HDNode.toWIF(node), + fundingAddress: BCH.SLP.Address.toSLPAddress(cashAddress), + legacyAddress: BCH.SLP.Address.toLegacyAddress(cashAddress), + }; + }; + + const haveUtxosChanged = (utxos, previousUtxos) => { + // Relevant points for this array comparing exercise + // https://stackoverflow.com/questions/13757109/triple-equal-signs-return-false-for-arrays-in-javascript-why + // https://stackoverflow.com/questions/7837456/how-to-compare-arrays-in-javascript + + // If this is initial state + if (utxos === null) { + // Then make sure to get slpBalancesAndUtxos + return true; + } + // If this is the first time the wallet received utxos + if ( + typeof previousUtxos === 'undefined' || + typeof utxos === 'undefined' + ) { + // Then they have certainly changed + return true; + } + // return true for empty array, since this means you definitely do not want to skip the next API call + if (utxos && utxos.length === 0) { + return true; + } + + // Compare utxo sets + const utxoArraysUnchanged = _.isEqual(utxos, previousUtxos); + + // If utxos are not the same as previousUtxos + if (utxoArraysUnchanged) { + // then utxos have not changed + return false; + // otherwise, + } else { + // utxos have changed + return true; + } + }; + + const update = async ({ wallet, setWalletState }) => { + //console.log(`tick()`); + //console.time("update"); + try { + if (!wallet) { + return; + } + const cashAddresses = [ + wallet.Path245.cashAddress, + wallet.Path145.cashAddress, + ]; + + const utxos = await getUtxos(BCH, cashAddresses); + //console.log(`utxos`, utxos); + + // If an error is returned or utxos from only 1 address are returned + if (utxos.error || utxos.length < 2) { + // Throw error here to prevent more attempted api calls + // as you are likely already at rate limits + throw new Error('Error fetching utxos'); + } + setUtxos(utxos); + + const utxosHaveChanged = haveUtxosChanged(utxos, previousUtxos); + + // If the utxo set has not changed, + if (!utxosHaveChanged) { + // remove api error here; otherwise it will remain if recovering from a rate + // limit error with an unchanged utxo set + setApiError(false); + // then walletState has not changed and does not need to be updated + //console.timeEnd("update"); + return; + } + + // todo: another available optimization, update slpBalancesandUtxos by hydrating only the new utxos + const slpBalancesAndUtxos = await getSlpBalancesAndUtxos( + BCH, + utxos, + ); + const txHistory = await getTxHistory(BCH, cashAddresses); + + console.log(`slpBalancesAndUtxos`, slpBalancesAndUtxos); + if (typeof slpBalancesAndUtxos === 'undefined') { + console.log(`slpBalancesAndUtxos is undefined`); + throw new Error('slpBalancesAndUtxos is undefined'); + } + const { tokens } = slpBalancesAndUtxos; + + const newState = { + balances: {}, + tokens: [], + slpBalancesAndUtxos: [], + }; + + newState.slpBalancesAndUtxos = normalizeSlpBalancesAndUtxos( + slpBalancesAndUtxos, + wallet, + ); + + newState.balances = normalizeBalance(slpBalancesAndUtxos); + + newState.tokens = tokens; + + newState.txHistory = txHistory; + + setWalletState(newState); + + // If everything executed correctly, remove apiError + setApiError(false); + } catch (error) { + console.log(`Error in update({wallet, setWalletState})`); + console.log(error); + // Set this in state so that transactions are disabled until the issue is resolved + setApiError(true); + //console.timeEnd("update"); + } + //console.timeEnd("update"); + }; + + const getWallet = async () => { + let wallet; + try { + let existingWallet; + try { + existingWallet = await localforage.getItem('wallet'); + // If not in localforage then existingWallet = false, check localstorage + if (!existingWallet) { + console.log(`no existing wallet, checking local storage`); + existingWallet = JSON.parse( + window.localStorage.getItem('wallet'), + ); + console.log( + `existingWallet from localStorage`, + existingWallet, + ); + // If you find it here, move it to indexedDb + if (existingWallet !== null) { + wallet = await getWalletDetails(existingWallet); + await localforage.setItem('wallet', wallet); + return wallet; + } + } + } catch (e) { + console.log(e); + existingWallet = null; + } + // If no wallet in indexedDb or localforage or caught error above or the initial 'false' is in indexedDB + if (existingWallet === null || !existingWallet) { + wallet = await getWalletDetails(existingWallet); + await localforage.setItem('wallet', wallet); + } else { + wallet = existingWallet; + } + + // todo: only do this if you didn't get it out of storage + //wallet = await getWalletDetails(existingWallet); + //await localforage.setItem("wallet", wallet); + } catch (error) { + console.log(error); + } + return wallet; + }; + + const getWalletDetails = async wallet => { + if (!wallet) { + return false; + } + // Since this info is in localforage now, only get the var + const NETWORK = process.env.REACT_APP_NETWORK; + const mnemonic = wallet.mnemonic; + const rootSeedBuffer = await BCH.Mnemonic.toSeed(mnemonic); + let masterHDNode; + + if (NETWORK === `mainnet`) + masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer); + else masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer, 'testnet'); + + const Path245 = await deriveAccount({ + masterHDNode, + path: "m/44'/245'/0'/0/0", + }); + const Path145 = await deriveAccount({ + masterHDNode, + path: "m/44'/145'/0'/0/0", + }); + + let name = Path145.cashAddress.slice(12, 17); + // Only set the name if it does not currently exist + if (wallet && wallet.name) { + name = wallet.name; + } + + return { + mnemonic: wallet.mnemonic, + name, + Path245, + Path145, + }; + }; + + const getSavedWallets = async activeWallet => { + let savedWallets; + try { + savedWallets = await localforage.getItem('savedWallets'); + if (savedWallets === null) { + savedWallets = []; + } + } catch (err) { + console.log(`Error in getSavedWallets`); + console.log(err); + savedWallets = []; + } + // Even though the active wallet is still stored in savedWallets, don't return it in this function + for (let i = 0; i < savedWallets.length; i += 1) { + if ( + typeof activeWallet !== 'undefined' && + activeWallet.name && + savedWallets[i].name === activeWallet.name + ) { + savedWallets.splice(i, 1); + } + } + return savedWallets; + }; + + const activateWallet = async walletToActivate => { + /* + If the user is migrating from old version to this version, make sure to save the activeWallet + + 1 - check savedWallets for the previously active wallet + 2 - If not there, add it + */ + let currentlyActiveWallet; + try { + currentlyActiveWallet = await localforage.getItem('wallet'); + } catch (err) { + console.log( + `Error in localforage.getItem("wallet") in activateWallet()`, + ); + return false; + } + // Get savedwallets + let savedWallets; + try { + savedWallets = await localforage.getItem('savedWallets'); + } catch (err) { + console.log( + `Error in localforage.getItem("savedWallets") in activateWallet()`, + ); + return false; + } + + // Check savedWallets for currentlyActiveWallet + let walletInSavedWallets = false; + for (let i = 0; i < savedWallets.length; i += 1) { + if (savedWallets[i].name === currentlyActiveWallet.name) { + walletInSavedWallets = true; + } + } + if (!walletInSavedWallets) { + console.log(`Wallet is not in saved Wallets, adding`); + savedWallets.push(currentlyActiveWallet); + // resave savedWallets + try { + // Set walletName as the active wallet + await localforage.setItem('savedWallets', savedWallets); + } catch (err) { + console.log( + `Error in localforage.setItem("savedWallets") in activateWallet()`, + ); + } + } + + // Now that we have verified the last wallet was saved, we can activate the new wallet + try { + await localforage.setItem('wallet', walletToActivate); + } catch (err) { + console.log( + `Error in localforage.setItem("wallet", walletToActivate) in activateWallet()`, + ); + return false; + } + return walletToActivate; + }; + + const renameWallet = async (oldName, newName) => { + // Load savedWallets + let savedWallets; + try { + savedWallets = await localforage.getItem('savedWallets'); + } catch (err) { + console.log( + `Error in await localforage.getItem("savedWallets") in renameWallet`, + ); + console.log(err); + return false; + } + // Verify that no existing wallet has this name + for (let i = 0; i < savedWallets.length; i += 1) { + if (savedWallets[i].name === newName) { + // return an error + return false; + } + } + + // change name of desired wallet + for (let i = 0; i < savedWallets.length; i += 1) { + if (savedWallets[i].name === oldName) { + // Replace the name of this entry with the new name + savedWallets[i].name = newName; + } + } + // resave savedWallets + try { + // Set walletName as the active wallet + await localforage.setItem('savedWallets', savedWallets); + } catch (err) { + console.log( + `Error in localforage.setItem("savedWallets", savedWallets) in renameWallet()`, + ); + return false; + } + return true; + }; + + const deleteWallet = async walletToBeDeleted => { + // delete a wallet + // returns true if wallet is successfully deleted + // otherwise returns false + // Load savedWallets + let savedWallets; + try { + savedWallets = await localforage.getItem('savedWallets'); + } catch (err) { + console.log( + `Error in await localforage.getItem("savedWallets") in deleteWallet`, + ); + console.log(err); + return false; + } + // Iterate over to find the wallet to be deleted + // Verify that no existing wallet has this name + let walletFoundAndRemoved = false; + for (let i = 0; i < savedWallets.length; i += 1) { + if (savedWallets[i].name === walletToBeDeleted.name) { + // Verify it has the same mnemonic too, that's a better UUID + if (savedWallets[i].mnemonic === walletToBeDeleted.mnemonic) { + // Delete it + savedWallets.splice(i, 1); + walletFoundAndRemoved = true; + } + } + } + // If you don't find the wallet, return false + if (!walletFoundAndRemoved) { + return false; + } + + // Resave savedWallets less the deleted wallet + try { + // Set walletName as the active wallet + await localforage.setItem('savedWallets', savedWallets); + } catch (err) { + console.log( + `Error in localforage.setItem("savedWallets", savedWallets) in deleteWallet()`, + ); + return false; + } + return true; + }; + + const addNewSavedWallet = async importMnemonic => { + // Add a new wallet to savedWallets from importMnemonic or just new wallet + const lang = 'english'; + // create 128 bit BIP39 mnemonic + const Bip39128BitMnemonic = importMnemonic + ? importMnemonic + : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); + const newSavedWallet = await getWalletDetails({ + mnemonic: Bip39128BitMnemonic.toString(), + }); + // Get saved wallets + let savedWallets; + try { + savedWallets = await localforage.getItem('savedWallets'); + // If this doesn't exist yet, savedWallets === null + if (savedWallets === null) { + savedWallets = []; + } + } catch (err) { + console.log( + `Error in savedWallets = await localforage.getItem("savedWallets") in addNewSavedWallet()`, + ); + console.log(err); + console.log(`savedWallets in error state`, savedWallets); + } + // If this wallet is from an imported mnemonic, make sure it does not already exist in savedWallets + if (importMnemonic) { + for (let i = 0; i < savedWallets.length; i += 1) { + // Check for condition "importing new wallet that is already in savedWallets" + if (savedWallets[i].mnemonic === importMnemonic) { + // set this as the active wallet to keep name history + console.log( + `Error: this wallet already exists in savedWallets`, + ); + console.log(`Wallet not being added.`); + return false; + } + } + } + // add newSavedWallet + savedWallets.push(newSavedWallet); + // update savedWallets + try { + await localforage.setItem('savedWallets', savedWallets); + } catch (err) { + console.log( + `Error in localforage.setItem("savedWallets", activeWallet) called in createWallet with ${importMnemonic}`, + ); + console.log(`savedWallets`, savedWallets); + console.log(err); + } + return true; + }; + + const createWallet = async importMnemonic => { + const lang = 'english'; + // create 128 bit BIP39 mnemonic + const Bip39128BitMnemonic = importMnemonic + ? importMnemonic + : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); + const wallet = await getWalletDetails({ + mnemonic: Bip39128BitMnemonic.toString(), + }); + + try { + await localforage.setItem('wallet', wallet); + } catch (err) { + console.log( + `Error setting wallet to wallet indexedDb in createWallet()`, + ); + console.log(err); + } + // Since this function is only called from OnBoarding.js, also add this to the saved wallet + try { + await localforage.setItem('savedWallets', [wallet]); + } catch (err) { + console.log( + `Error setting wallet to savedWallets indexedDb in createWallet()`, + ); + console.log(err); + } + return wallet; + }; + + const validateMnemonic = ( + mnemonic, + wordlist = BCH.Mnemonic.wordLists().english, + ) => { + let mnemonicTestOutput; + + try { + mnemonicTestOutput = BCH.Mnemonic.validate(mnemonic, wordlist); + + if (mnemonicTestOutput === 'Valid mnemonic') { + return true; + } else { + return false; + } + } catch (err) { + console.log(err); + return false; + } + }; + + const handleUpdateWallet = async setWallet => { + const wallet = await getWallet(); + setWallet(wallet); + }; + + // Parse for incoming BCH transactions + // Only notify if websocket is not connected + if ( + (ws === null || ws.readyState !== 1) && + previousBalances && + balances && + 'totalBalance' in previousBalances && + 'totalBalance' in balances && + new BigNumber(balances.totalBalance) + .minus(previousBalances.totalBalance) + .gt(0) + ) { + notification.success({ + message: 'Transaction received', + description: ( + + You received{' '} + {Number( + balances.totalBalance - previousBalances.totalBalance, + ).toFixed(8)}{' '} + BCH! + + ), + duration: 3, + }); + } + + // Parse for incoming SLP transactions + if ( + tokens && + tokens[0] && + tokens[0].balance && + previousTokens && + previousTokens[0] && + previousTokens[0].balance + ) { + // If tokens length is greater than previousTokens length, a new token has been received + // Note, a user could receive a new token, AND more of existing tokens in between app updates + // In this case, the app will only notify about the new token + // TODO better handling for all possible cases to cover this + // TODO handle with websockets for better response time, less complicated calc + if (tokens.length > previousTokens.length) { + // Find the new token + const tokenIds = tokens.map(({ tokenId }) => tokenId); + const previousTokenIds = previousTokens.map( + ({ tokenId }) => tokenId, + ); + //console.log(`tokenIds`, tokenIds); + //console.log(`previousTokenIds`, previousTokenIds); + + // An array with the new token Id + const newTokenIdArr = tokenIds.filter( + tokenId => !previousTokenIds.includes(tokenId), + ); + // It's possible that 2 new tokens were received + // To do, handle this case + const newTokenId = newTokenIdArr[0]; + //console.log(newTokenId); + + // How much of this tokenId did you get? + // would be at + + // Find where the newTokenId is + const receivedTokenObjectIndex = tokens.findIndex( + x => x.tokenId === newTokenId, + ); + //console.log(`receivedTokenObjectIndex`, receivedTokenObjectIndex); + // Calculate amount received + //console.log(`receivedTokenObject:`, tokens[receivedTokenObjectIndex]); + + const receivedSlpQty = tokens[ + receivedTokenObjectIndex + ].balance.toString(); + const receivedSlpTicker = + tokens[receivedTokenObjectIndex].info.tokenTicker; + const receivedSlpName = + tokens[receivedTokenObjectIndex].info.tokenName; + //console.log(`receivedSlpQty`, receivedSlpQty); + + // Notification + notification.success({ + message: `SLP Transaction received: ${receivedSlpTicker}`, + description: ( + + You received {receivedSlpQty} {receivedSlpName} + + ), + duration: 5, + }); + + // + } else { + // If tokens[i].balance > previousTokens[i].balance, a new SLP tx of an existing token has been received + for (let i = 0; i < tokens.length; i += 1) { + if (tokens[i].balance > previousTokens[i].balance) { + // Received this token + // console.log(`previousTokenId`, previousTokens[i].tokenId); + // console.log(`currentTokenId`, tokens[i].tokenId); + if (previousTokens[i].tokenId !== tokens[i].tokenId) { + console.log( + `TokenIds do not match, breaking from SLP notifications`, + ); + // Then don't send the notification + // Also don't 'continue' ; this means you have sent a token, just stop iterating through + break; + } + const receivedSlpDecimals = tokens[i].info.decimals; + const receivedSlpQty = ( + tokens[i].balance - previousTokens[i].balance + ).toFixed(receivedSlpDecimals); + const receivedSlpTicker = tokens[i].info.tokenTicker; + const receivedSlpName = tokens[i].info.tokenName; + + notification.success({ + message: `SLP Transaction received: ${receivedSlpTicker}`, + description: ( + + You received {receivedSlpQty} {receivedSlpName} + + ), + duration: 5, + }); + } + } + } + } + + // Update price every 1 min + useAsyncTimeout(async () => { + fetchBchPrice(); + }, 60000); + + // Update wallet every 10s + useAsyncTimeout(async () => { + const wallet = await getWallet(); + update({ + wallet, + setWalletState, + }).finally(() => { + setLoading(false); + }); + }, 10000); + + const initializeWebsocket = (cashAddress, slpAddress) => { + // console.log(`initializeWebsocket(${cashAddress}, ${slpAddress})`); + // This function parses 3 cases + // 1: edge case, websocket is in state but not properly connected + // > Remove it from state and forget about it, fall back to normal notifications + // 2: edge-ish case, websocket is in state and connected but user has changed wallet + // > Unsubscribe from old addresses and subscribe to new ones + // 3: most common: app is opening, creating websocket with existing addresses + + // If the websocket is already in state but is not properly connected + if (ws !== null && ws.readyState !== 1) { + // Forget about it and use conventional notifications + + // Close + ws.close(); + // Remove from state + setWs(null); + } + // If the websocket is in state and connected + else if (ws !== null) { + // console.log(`Websocket already in state`); + // console.log(`ws,`, ws); + // instead of initializing websocket, unsubscribe from old addresses and subscribe to new ones + const previousWsCashAddress = previousWallet.Path145.legacyAddress; + const previousWsSlpAddress = previousWallet.Path245.legacyAddress; + try { + // Unsubscribe from previous addresses + ws.send( + JSON.stringify({ + op: 'addr_unsub', + addr: previousWsCashAddress, + }), + ); + console.log( + `Unsubscribed from BCH address at ${previousWsCashAddress}`, + ); + ws.send( + JSON.stringify({ + op: 'addr_unsub', + addr: previousWsSlpAddress, + }), + ); + console.log( + `Unsubscribed from SLP address at ${previousWsSlpAddress}`, + ); + + // Subscribe to new addresses + ws.send(JSON.stringify({ op: 'addr_sub', addr: cashAddress })); + console.log(`Subscribed to BCH address at ${cashAddress}`); + // Subscribe to SLP address + ws.send( + JSON.stringify({ + op: 'addr_sub', + addr: slpAddress, + }), + ); + console.log(`Subscribed to SLP address at ${slpAddress}`); + // Reset onmessage; it was previously set with the old addresses + // Note this code is exactly identical to lines 431-490 + // TODO put in function + ws.onmessage = e => { + // TODO handle case where receive multiple messages on one incoming transaction + //console.log(`ws msg received`); + const incomingTx = JSON.parse(e.data); + console.log(incomingTx); + + let bchSatsReceived = 0; + // First, check the inputs + // If cashAddress or slpAddress are in the inputs, then this is a sent tx and should be ignored for notifications + if ( + incomingTx && + incomingTx.x && + incomingTx.x.inputs && + incomingTx.x.out + ) { + const inputs = incomingTx.x.inputs; + // Iterate over inputs and see if this transaction was sent by the active wallet + for (let i = 0; i < inputs.length; i += 1) { + if ( + inputs[i].prev_out.addr === cashAddress || + inputs[i].prev_out.addr === slpAddress + ) { + // console.log(`Found a sending tx, not notifying`); + // This is a sent transaction and should be ignored by notification handlers + return; + } + } + // Iterate over outputs to determine receiving address + const outputs = incomingTx.x.out; + + for (let i = 0; i < outputs.length; i += 1) { + if (outputs[i].addr === cashAddress) { + // console.log(`BCH transaction received`); + bchSatsReceived += outputs[i].value; + // handle + } + if (outputs[i].addr === slpAddress) { + console.log(`SLP transaction received`); + //handle + // you would want to get the slp info using this endpoint: + // https://rest.kingbch.com/v3/slp/txDetails/cb39dd04e07e172a37addfcb1d6e167dc52c01867ba21c9bf8b5acf4dd969a3f + // But it does not work for unconfirmed txs + // Hold off on slp tx notifications for now + } + } + } + // parse for receiving address + // if received at cashAddress, parse for BCH amount, notify BCH received + // if received at slpAddress, parse for token, notify SLP received + // if those checks fail, could be from a 'sent' tx, ignore + + // Note, when you send an SLP tx, you get SLP change to SLP address and BCH change to BCH address + + // Also note, when you send an SLP tx, you often have inputs from both BCH and SLP addresses + + // This causes a sent SLP tx to register 4 times from the websocket + + // Best way to ignore this is to ignore any incoming utx.x with BCH or SLP address in the inputs + + // Notification for received BCH + if (bchSatsReceived > 0) { + notification.success({ + message: 'Transaction received', + description: ( + + You received {bchSatsReceived / 1e8} BCH! + + ), + duration: 3, + }); + } + }; + } catch (err) { + console.log( + `Error attempting to configure websocket for new wallet`, + ); + console.log(err); + console.log(`Closing connection`); + ws.close(); + setWs(null); + } + } else { + // If there is no websocket, create one, subscribe to addresses, and add notifications for incoming BCH transactions + + let newWs = new WebSocket('wss://ws.blockchain.info/bch/inv'); + + newWs.onopen = () => { + console.log(`Connected to bchWs`); + + // Subscribe to BCH address + newWs.send( + JSON.stringify({ op: 'addr_sub', addr: cashAddress }), + ); + + console.log(`Subscribed to BCH address at ${cashAddress}`); + // Subscribe to SLP address + newWs.send( + JSON.stringify({ + op: 'addr_sub', + addr: slpAddress, + }), + ); + console.log(`Subscribed to SLP address at ${slpAddress}`); + }; + newWs.onerror = e => { + // close and set to null + console.log(`Error in websocket connection for ${newWs}`); + console.log(e); + setWs(null); + }; + newWs.onclose = () => { + console.log(`Websocket connection closed`); + // Unsubscribe on close to prevent double subscribing + //{"op":"addr_unsub", "addr":"$bitcoin_address"} + newWs.send( + JSON.stringify({ op: 'addr_unsub', addr: cashAddress }), + ); + console.log(`Unsubscribed from BCH address at ${cashAddress}`); + newWs.send( + JSON.stringify({ + op: 'addr_sub', + addr: slpAddress, + }), + ); + console.log(`Unsubscribed from SLP address at ${slpAddress}`); + }; + newWs.onmessage = e => { + // TODO handle case where receive multiple messages on one incoming transaction + //console.log(`ws msg received`); + const incomingTx = JSON.parse(e.data); + console.log(incomingTx); + + let bchSatsReceived = 0; + // First, check the inputs + // If cashAddress or slpAddress are in the inputs, then this is a sent tx and should be ignored for notifications + if ( + incomingTx && + incomingTx.x && + incomingTx.x.inputs && + incomingTx.x.out + ) { + const inputs = incomingTx.x.inputs; + // Iterate over inputs and see if this transaction was sent by the active wallet + for (let i = 0; i < inputs.length; i += 1) { + if ( + inputs[i].prev_out.addr === cashAddress || + inputs[i].prev_out.addr === slpAddress + ) { + // console.log(`Found a sending tx, not notifying`); + // This is a sent transaction and should be ignored by notification handlers + return; + } + } + // Iterate over outputs to determine receiving address + const outputs = incomingTx.x.out; + + for (let i = 0; i < outputs.length; i += 1) { + if (outputs[i].addr === cashAddress) { + // console.log(`BCH transaction received`); + bchSatsReceived += outputs[i].value; + // handle + } + if (outputs[i].addr === slpAddress) { + console.log(`SLP transaction received`); + //handle + // you would want to get the slp info using this endpoint: + // https://rest.kingbch.com/v3/slp/txDetails/cb39dd04e07e172a37addfcb1d6e167dc52c01867ba21c9bf8b5acf4dd969a3f + // But it does not work for unconfirmed txs + // Hold off on slp tx notifications for now + } + } + } + // parse for receiving address + // if received at cashAddress, parse for BCH amount, notify BCH received + // if received at slpAddress, parse for token, notify SLP received + // if those checks fail, could be from a 'sent' tx, ignore + + // Note, when you send an SLP tx, you get SLP change to SLP address and BCH change to BCH address + + // Also note, when you send an SLP tx, you often have inputs from both BCH and SLP addresses + + // This causes a sent SLP tx to register 4 times from the websocket + + // Best way to ignore this is to ignore any incoming utx.x with BCH or SLP address in the inputs + + // Notification for received BCH + if (bchSatsReceived > 0) { + notification.success({ + message: 'Transaction received', + description: ( + + You received {bchSatsReceived / 1e8} BCH! + + ), + duration: 3, + }); + } + }; + + setWs(newWs); + } + }; + + const fetchBchPrice = async () => { + // Split this variable out in case coingecko changes + const cryptoId = currency.coingeckoId; + // Keep currency as a variable as eventually it will be a user setting + const fiatCode = 'usd'; + // Keep this in the code, because different URLs will have different outputs require different parsing + const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`; + let bchPrice; + let bchPriceJson; + try { + bchPrice = await fetch(priceApiUrl); + //console.log(`bchPrice`, bchPrice); + } catch (err) { + console.log(`Error fetching BCH Price`); + console.log(err); + } + try { + bchPriceJson = await bchPrice.json(); + //console.log(`bchPriceJson`, bchPriceJson); + const bchPriceInFiat = bchPriceJson[cryptoId][fiatCode]; + //console.log(`bchPriceInFiat`, bchPriceInFiat); + setFiatPrice(bchPriceInFiat); + } catch (err) { + console.log(`Error parsing price API response to JSON`); + console.log(err); + } + }; + + useEffect(() => { + handleUpdateWallet(setWallet); + fetchBchPrice(); + }, []); + + useEffect(() => { + if ( + wallet && + wallet.Path145 && + wallet.Path145.cashAddress && + wallet.Path245 && + wallet.Path245.cashAddress + ) { + if (currency.useBlockchainWs) { + initializeWebsocket( + wallet.Path145.legacyAddress, + wallet.Path245.legacyAddress, + ); + } + } + }, [wallet]); + + return { + BCH, + wallet, + fiatPrice, + slpBalancesAndUtxos, + balances, + tokens, + txHistory, + loading, + apiError, + getWallet, + validateMnemonic, + getWalletDetails, + getSavedWallets, + update: async () => + update({ + wallet: await getWallet(), + setLoading, + setWalletState, + }), + createWallet: async importMnemonic => { + setLoading(true); + const newWallet = await createWallet(importMnemonic); + setWallet(newWallet); + update({ + wallet: newWallet, + setWalletState, + }).finally(() => setLoading(false)); + }, + activateWallet: async walletToActivate => { + setLoading(true); + const newWallet = await activateWallet(walletToActivate); + setWallet(newWallet); + update({ + wallet: newWallet, + setWalletState, + }).finally(() => setLoading(false)); + }, + addNewSavedWallet, + renameWallet, + deleteWallet, + }; +}; + +export default useWallet; diff --git a/web/cashtab/src/hooks/useWindowDimensions.js b/web/cashtab/src/hooks/useWindowDimensions.js new file mode 100644 index 000000000..91177ac7d --- /dev/null +++ b/web/cashtab/src/hooks/useWindowDimensions.js @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; + +// From this stack overflow thread: +// https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs + +function getWindowDimensions() { + const { innerWidth: width, innerHeight: height } = window; + return { + width, + height, + }; +} + +export default function useWindowDimensions() { + const [windowDimensions, setWindowDimensions] = useState( + getWindowDimensions(), + ); + + useEffect(() => { + function handleResize() { + setWindowDimensions(getWindowDimensions()); + } + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return windowDimensions; +} diff --git a/web/cashtab/src/index.css b/web/cashtab/src/index.css new file mode 100644 index 000000000..7a0309e9c --- /dev/null +++ b/web/cashtab/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: 'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', + 'Roboto', 'Oxygen', 'Cantarell', 'Fira Sans', 'Droid Sans', + 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/web/cashtab/src/index.js b/web/cashtab/src/index.js new file mode 100644 index 000000000..9d36c566b --- /dev/null +++ b/web/cashtab/src/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import App from './components/App'; +import { WalletProvider } from './utils/context'; +import { HashRouter as Router } from 'react-router-dom'; +import GA from './utils/GoogleAnalytics'; + +ReactDOM.render( + + + {GA.init() && } + + + , + document.getElementById('root'), +); + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => + navigator.serviceWorker.register('/serviceWorker.js').catch(() => null), + ); +} + +if (module.hot) { + module.hot.accept(); +} diff --git a/web/cashtab/src/serviceWorker.js b/web/cashtab/src/serviceWorker.js new file mode 100644 index 000000000..69ab9703d --- /dev/null +++ b/web/cashtab/src/serviceWorker.js @@ -0,0 +1,52 @@ +workbox.setConfig({ + debug: false, +}); + +workbox.core.skipWaiting(); +workbox.core.clientsClaim(); + +const cachedPathNames = [ + '/v2/transaction/details', + '/v2/rawtransactions/getRawTransaction', + '/v2/slp/validateTxid', +]; + +workbox.routing.registerRoute( + ({ url, event }) => + cachedPathNames.some(cachedPathName => + url.pathname.includes(cachedPathName), + ), + async ({ event, url }) => { + try { + const cache = await caches.open('api-cache'); + const cacheKeys = await cache.keys(); + if (cacheKeys.length > 100) { + await Promise.all(cacheKeys.map(key => cache.delete(key))); + } + const requestBody = await event.request.clone().text(); + + try { + const response = await cache.match( + `${url.pathname}/${requestBody}`, + ); + if (!response) { + throw new Error('SW: Not cached!'); + } + return response; + } catch (error) { + const response = await fetch(event.request.clone()); + if (response.status === 200) { + const body = await response.clone().text(); + cache.put( + `${url.pathname}/${requestBody}`, + new Response(body, { status: 200 }), + ); + } + return response.clone(); + } + } catch (err) { + return fetch(event.request.clone()); + } + }, + 'POST', +); diff --git a/web/cashtab/src/utils/GoogleAnalytics.js b/web/cashtab/src/utils/GoogleAnalytics.js new file mode 100644 index 000000000..ecb24cc68 --- /dev/null +++ b/web/cashtab/src/utils/GoogleAnalytics.js @@ -0,0 +1,75 @@ +// utils/GoogleAnalytics.js +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import * as ReactGA from 'react-ga'; +import { Route } from 'react-router-dom'; + +class GoogleAnalytics extends Component { + componentDidMount() { + this.logPageChange( + this.props.location.pathname, + this.props.location.search, + ); + } + + componentDidUpdate({ location: prevLocation }) { + const { + location: { pathname, search }, + } = this.props; + const isDifferentPathname = pathname !== prevLocation.pathname; + const isDifferentSearch = search !== prevLocation.search; + + if (isDifferentPathname || isDifferentSearch) { + this.logPageChange(pathname, search); + } + } + + logPageChange(pathname, search = '') { + const page = pathname + search; + const { location } = window; + ReactGA.set({ + page, + location: `${location.origin}${page}`, + ...this.props.options, + }); + ReactGA.pageview(page); + } + + render() { + return null; + } +} + +GoogleAnalytics.propTypes = { + location: PropTypes.shape({ + pathname: PropTypes.string, + search: PropTypes.string, + }).isRequired, + options: PropTypes.object, +}; + +const RouteTracker = () => ; + +const init = (options = {}) => { + const isGAEnabled = process.env.NODE_ENV === 'production'; + + if (isGAEnabled) { + ReactGA.initialize('UA-183678810-1'); + } + + return isGAEnabled; +}; + +export const Event = (category, action, label) => { + ReactGA.event({ + category: category, + action: action, + label: label, + }); +}; + +export default { + GoogleAnalytics, + RouteTracker, + init, +}; diff --git a/web/cashtab/src/utils/context.js b/web/cashtab/src/utils/context.js new file mode 100644 index 000000000..b79b8ec34 --- /dev/null +++ b/web/cashtab/src/utils/context.js @@ -0,0 +1,12 @@ +import React from 'react'; +import useWallet from '../hooks/useWallet'; +export const WalletContext = React.createContext(); + +export const WalletProvider = ({ children }) => { + const wallet = useWallet(); + return ( + + {children} + + ); +}; diff --git a/web/cashtab/src/utils/debounce.js b/web/cashtab/src/utils/debounce.js new file mode 100644 index 000000000..bd1f2be86 --- /dev/null +++ b/web/cashtab/src/utils/debounce.js @@ -0,0 +1,13 @@ +export default (routine, timeout = 500) => { + let timeoutId; + + return (...args) => { + return new Promise((resolve, reject) => { + clearTimeout(timeoutId); + timeoutId = setTimeout( + () => Promise.resolve(routine(...args)).then(resolve, reject), + timeout, + ); + }); + }; +}; diff --git a/web/cashtab/src/utils/retry.js b/web/cashtab/src/utils/retry.js new file mode 100644 index 000000000..eb46d1fa1 --- /dev/null +++ b/web/cashtab/src/utils/retry.js @@ -0,0 +1,12 @@ +export const retry = async (func, { delay = 100, tries = 3 } = {}) => { + try { + return await Promise.resolve(func()); + } catch (error) { + if (tries === 0) { + throw error; + } + return await retry(func, { delay: delay * 2, tries: tries - 1 }); + } +}; + +export default retry;