diff --git a/web/cashtab-components/package.json b/web/cashtab-components/package.json index fca0637db..475d2381f 100644 --- a/web/cashtab-components/package.json +++ b/web/cashtab-components/package.json @@ -1,125 +1,125 @@ { "name": "cashtab-components", - "version": "1.0.0", + "version": "1.0.1", "description": "Integrate the Cashtab Wallet into your React app with ease.", "main": "dist/index", "typings": "dist/index", "peerDependencies": { "react": "^17.0.1", "react-dom": "^17.0.1" }, "dependencies": { "@babel/preset-typescript": "^7.12.7", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@types/node": "^12.0.0", "@types/react": "^16.9.53", "@types/react-dom": "^16.9.8", "bignumber.js": "^9.0.1", "qrcode.react": "^1.0.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-scripts": "4.0.1", "styled-components": "4.4.1", "typescript": "^4.0.3", "web-vitals": "^0.2.4", "webpack": "^4.44.2", "webpack-bundle-analyzer": "^4.3.0", "webpack-cli": "^4.3.0" }, "scripts": { "start": "npm run storybook", "build": "rm -rf dist/ && webpack --mode=production", "prepublish": "npm run build", "test": "jest", "eject": "react-scripts eject", "storybook": "start-storybook -p 6006 -s public", "build-storybook": "build-storybook -s public" }, "repository": { "type": "git", "url": "git+https://github.com/josephroyking/cashtab-components.git" }, "keywords": [ "cashtab", "bitcoin abc", "ecash", "XEC", "etoken", "web payments", "react component", "cashtab button", "crypto payments", "crypto" ], "author": "Joey King ", "bugs": { "url": "https://github.com/josephroyking/cashtab-components/issues" }, "homepage": "https://github.com/josephroyking/cashtab-components#readme", "license": "MIT", "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@babel/core": "^7.12.10", "@babel/preset-env": "^7.12.11", "@storybook/addon-actions": "^6.1.11", "@storybook/addon-essentials": "^6.1.11", "@storybook/addon-knobs": "^6.1.11", "@storybook/addon-links": "^6.1.11", "@storybook/addons": "^6.1.11", "@storybook/node-logger": "^6.1.11", "@storybook/preset-create-react-app": "^3.1.5", "@storybook/react": "^6.1.11", "@storybook/theming": "^6.1.11", "@types/jest": "^26.0.19", "@types/lodash": "^4.14.165", "@types/qrcode.react": "^1.0.1", "@types/styled-components": "^5.1.7", "babel-jest": "^26.6.3", "jest": "^26.6.0", "jest-environment-jsdom-fifteen": "^1.0.2", "jest-junit": "^12.0.0", "ts-loader": "^8.0.12" }, "jest": { "roots": [ "/src" ], "setupFiles": [ "react-app-polyfill/jsdom" ], "testMatch": [ "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" ], "testEnvironment": "jest-environment-jsdom-fifteen", "transform": { "^.+\\.(js|jsx|ts|tsx)$": "/node_modules/babel-jest" }, "transformIgnorePatterns": [ "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$", "^.+\\.module\\.(css|sass|scss)$" ], "moduleNameMapper": { ".+\\.(css|styl|less|sass|scss|png|jpg|svg|ttf|woff|woff2)$": "identity-obj-proxy" } } } diff --git a/web/cashtab-components/src/hoc/CashtabBase/CashtabBase.tsx b/web/cashtab-components/src/hoc/CashtabBase/CashtabBase.tsx index 763fbc622..a81b32edf 100644 --- a/web/cashtab-components/src/hoc/CashtabBase/CashtabBase.tsx +++ b/web/cashtab-components/src/hoc/CashtabBase/CashtabBase.tsx @@ -1,435 +1,437 @@ import * as React from 'react'; import debounce from 'lodash/debounce'; import { fiatToSatoshis, adjustAmount, getAddressUnconfirmed, getTokenInfo, } from '../../utils/cashtab-helpers'; import Ticker from '../../atoms/Ticker'; import type { CurrencyCode } from '../../utils/currency-helpers'; declare global { interface Window { bitcoinAbc: any; } } interface sendParamsArr { to: string; protocol: ValidCoinTypes; value?: string; assetId?: string; opReturn?: string[]; } const SECOND = 1000; const PRICE_UPDATE_INTERVAL = 60 * SECOND; const REPEAT_TIMEOUT = 4 * SECOND; const URI_CHECK_INTERVAL = 10 * SECOND; // Whitelist of valid coinType. type ValidCoinTypes = string; // TODO - Install is a Cashtab state, others are payment states. Separate them to be independent type ButtonStates = 'fresh' | 'pending' | 'complete' | 'expired' | 'install'; type CashtabBaseProps = { to: string; stepControlled?: ButtonStates; // Both present to price in fiat equivalent currency: CurrencyCode; price?: number; // Both present to price in coinType absolute amount coinType: ValidCoinTypes; tokenId?: string; amount?: number; isRepeatable: boolean; repeatTimeout: number; watchAddress: boolean; opReturn?: string[]; showQR: boolean; // Intent to show QR. Only show if amount is BCH or fiat as OP_RETURN and SLP do not work with QR successFn?: Function; failFn?: Function; }; interface IState { step: ButtonStates; errors: string[]; satoshis?: number; // Used when converting fiat to BCH coinSymbol?: string; coinName?: string; coinDecimals?: number; unconfirmedCount?: number; intervalPrice?: NodeJS.Timeout; intervalUnconfirmed?: NodeJS.Timeout; intervalTimer?: NodeJS.Timeout; } const CashtabBase = (Wrapped: React.ComponentType) => { return class extends React.Component { static defaultProps = { currency: 'USD', coinType: Ticker.coinSymbol, isRepeatable: false, watchAddress: false, showQR: true, repeatTimeout: REPEAT_TIMEOUT, }; state = { step: 'fresh' as ButtonStates, satoshis: undefined, coinSymbol: undefined, coinDecimals: undefined, coinName: undefined, unconfirmedCount: undefined, intervalPrice: undefined, intervalUnconfirmed: undefined, intervalTimer: undefined, errors: [], }; addError = (error: string) => { const { errors } = this.state; this.setState({ errors: [...errors, error] }); }; startRepeatable = () => { const { repeatTimeout } = this.props; setTimeout(() => this.setState({ step: 'fresh' }), repeatTimeout); }; paymentSendSuccess = () => { const { isRepeatable } = this.props; const { intervalUnconfirmed, unconfirmedCount } = this.state; let unconfirmedCountInt; if (typeof unconfirmedCount === 'undefined') { unconfirmedCountInt = 0; } else { unconfirmedCountInt = unconfirmedCount; } this.setState({ step: 'complete', unconfirmedCount: unconfirmedCountInt ? unconfirmedCountInt + 1 : 1, }); if (isRepeatable) { this.startRepeatable(); } else { intervalUnconfirmed && clearInterval(intervalUnconfirmed); } }; getCashTabProviderStatus = () => { console.log(window.bitcoinAbc); if ( window && window.bitcoinAbc && window.bitcoinAbc === 'cashtab' ) { return true; } return false; }; handleClick = () => { const { amount, to, opReturn, coinType, tokenId } = this.props; const { satoshis } = this.state; // Satoshis might not set be set during server rendering if (!amount && !satoshis) { return; } const walletProviderStatus = this.getCashTabProviderStatus(); if (typeof window === `undefined` || !walletProviderStatus) { this.setState({ step: 'install' }); if (typeof window !== 'undefined') { window.open(Ticker.installLink); } return; } if (walletProviderStatus) { this.setState({ step: 'fresh' }); // Do not pass a token quantity to send, this is not yet supported in Cashtab if (coinType === Ticker.tokenName) { return; } return window.postMessage( { type: 'FROM_PAGE', text: 'CashTab', txInfo: { address: to, value: satoshis ? parseFloat( ( satoshis! * 10 ** (-1 * Ticker.coinDecimals) ).toFixed(2), ) : amount, }, }, '*', ); } const sendParams: sendParamsArr = { to, protocol: coinType, - value: amount?.toString() || adjustAmount(satoshis, 8, true), + value: + amount?.toString() || + adjustAmount(satoshis, Ticker.coinDecimals, true), }; if (coinType === Ticker.tokenTicker) { sendParams.assetId = tokenId; } if (opReturn && opReturn.length) { sendParams.opReturn = opReturn; } this.setState({ step: 'pending' }); /* May match this functionality later, may handle differently as above for Cashtab console.info('Cashtab sendAssets begin', sendParams); sendAssets(sendParams) .then(({ txid }: any) => { console.info('Cashtab send success:', txid); successFn && successFn(txid); this.paymentSendSuccess(); }) .catch((err: any) => { console.info('Cashtab send cancel', err); failFn && failFn(err); this.setState({ step: 'fresh' }); }); */ }; updateSatoshisFiat = debounce( async () => { const { price, currency } = this.props; if (!price) return; const satoshis = await fiatToSatoshis(currency, price); this.setState({ satoshis }); }, 250, { leading: true, trailing: true }, ); setupSatoshisFiat = () => { const { intervalPrice } = this.state; intervalPrice && clearInterval(intervalPrice); this.updateSatoshisFiat(); const intervalPriceNext = setInterval( () => this.updateSatoshisFiat(), PRICE_UPDATE_INTERVAL, ); this.setState({ intervalPrice: intervalPriceNext }); }; setupWatchAddress = async () => { const { to } = this.props; const { intervalUnconfirmed } = this.state; intervalUnconfirmed && clearInterval(intervalUnconfirmed); const initialUnconfirmed = await getAddressUnconfirmed(to); this.setState({ unconfirmedCount: initialUnconfirmed.length }); // Watch UTXO interval const intervalUnconfirmedNext = setInterval(async () => { const prevUnconfirmedCount = this.state.unconfirmedCount; const targetTransactions = await getAddressUnconfirmed(to); const unconfirmedCount = targetTransactions.length; this.setState({ unconfirmedCount }); if ( prevUnconfirmedCount != null && unconfirmedCount > prevUnconfirmedCount ) { this.paymentSendSuccess(); } }, URI_CHECK_INTERVAL); this.setState({ intervalUnconfirmed: intervalUnconfirmedNext }); }; setupCoinMeta = async () => { const { coinType, tokenId } = this.props; if (coinType === Ticker.coinSymbol) { this.setState({ coinSymbol: Ticker.coinSymbol, coinDecimals: Ticker.coinDecimals, coinName: Ticker.coinName, }); } else if (coinType === Ticker.tokenTicker && tokenId) { this.setState({ coinSymbol: undefined, coinName: undefined, coinDecimals: undefined, }); const tokenInfo = await getTokenInfo(tokenId); const { symbol, decimals, name } = tokenInfo; this.setState({ coinSymbol: symbol, coinDecimals: decimals, coinName: name, }); } }; confirmCashTabProviderStatus = () => { const cashTabStatus = this.getCashTabProviderStatus(); if (cashTabStatus) { this.setState({ step: 'fresh' }); } }; async componentDidMount() { if (typeof window !== 'undefined') { const { price, watchAddress } = this.props; // setup state, intervals, and listeners watchAddress && this.setupWatchAddress(); price && this.setupSatoshisFiat(); this.setupCoinMeta(); // normal call for setupCoinMeta() // Occasionially the cashtab window object is not available on componentDidMount, check later // TODO make this less hacky setTimeout(this.confirmCashTabProviderStatus, 750); // Detect CashTab and determine if button should show install CTA const walletProviderStatus = this.getCashTabProviderStatus(); if (walletProviderStatus) { this.setState({ step: 'fresh' }); } else { this.setState({ step: 'install' }); } } } componentWillUnmount() { const { intervalPrice, intervalUnconfirmed, intervalTimer, } = this.state; intervalPrice && clearInterval(intervalPrice); intervalUnconfirmed && clearInterval(intervalUnconfirmed); intervalTimer && clearInterval(intervalTimer); } componentDidUpdate(prevProps: CashtabBaseProps, prevState: IState) { if (typeof window !== 'undefined') { const { currency, price, isRepeatable, watchAddress, } = this.props; const prevCurrency = prevProps.currency; const prevPrice = prevProps.price; const prevIsRepeatable = prevProps.isRepeatable; const prevWatchAddress = prevProps.watchAddress; // Fiat price or currency changes if (currency !== prevCurrency || price !== prevPrice) { this.setupSatoshisFiat(); } if (isRepeatable && isRepeatable !== prevIsRepeatable) { this.startRepeatable(); } if (watchAddress !== prevWatchAddress) { if (watchAddress) { this.setupWatchAddress(); } else { const { intervalUnconfirmed } = this.state; intervalUnconfirmed && clearInterval(intervalUnconfirmed); } } } } render() { const { amount, showQR, opReturn, coinType, stepControlled, } = this.props; const { step, satoshis, coinDecimals, coinSymbol, coinName, } = this.state; let calculatedAmount = adjustAmount(amount, coinDecimals, false) || satoshis; // Only show QR if all requested features can be encoded in the BIP44 URI const shouldShowQR = showQR && coinType === Ticker.coinSymbol && (!opReturn || !opReturn.length); return ( ); } }; }; export type { CashtabBaseProps, ButtonStates, ValidCoinTypes, IState }; export default CashtabBase; diff --git a/web/cashtab/extension/public/manifest.json b/web/cashtab/extension/public/manifest.json index bb4bd489f..4e9ab0755 100644 --- a/web/cashtab/extension/public/manifest.json +++ b/web/cashtab/extension/public/manifest.json @@ -1,30 +1,30 @@ { "manifest_version": 2, "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "1.0.1", + "version": "1.0.2", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], "js": ["contentscript.js"], "run_at": "document_idle", "all_frames": true } ], "background": { "scripts": ["background.js"], "persistent": false }, "browser_action": { "default_popup": "index.html", "default_title": "Cashtab" }, "icons": { "16": "ecash16.png", "48": "ecash48.png", "128": "ecash128.png", "192": "ecash192.png", "512": "ecash512.png" } } diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js index de3bc7082..e06e94612 100644 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -1,191 +1,191 @@ import mainLogo from '@assets/logo_primary.png'; import tokenLogo from '@assets/logo_secondary.png'; import cashaddr from 'ecashaddrjs'; import BigNumber from 'bignumber.js'; export const currency = { name: 'eCash', ticker: 'XEC', logo: mainLogo, legacyPrefix: 'bitcoincash', prefixes: ['ecash'], coingeckoId: 'ecash', defaultFee: 2.01, dustSats: 550, etokenSats: 546, cashDecimals: 2, blockExplorerUrl: 'https://explorer.bitcoinabc.org', tokenExplorerUrl: 'https://explorer.be.cash', blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'eToken', tokenTicker: 'eToken', tokenLogo: tokenLogo, tokenPrefixes: ['etoken'], tokenIconsUrl: '', //https://tokens.bitcoin.com/32 for BCH SLP txHistoryCount: 5, hydrateUtxoBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, settingsValidation: { fiatCurrency: [ 'usd', 'idr', 'krw', 'cny', 'zar', 'vnd', 'cad', 'nok', 'eur', 'gbp', 'jpy', 'try', 'rub', 'inr', 'brl', ], }, fiatCurrencies: { usd: { name: 'US Dollar', symbol: '$', slug: 'usd' }, brl: { name: 'Brazilian Real', symbol: 'R$', slug: 'brl' }, gbp: { name: 'British Pound', symbol: '£', slug: 'gbp' }, cad: { name: 'Canadian Dollar', symbol: '$', slug: 'cad' }, cny: { name: 'Chinese Yuan', symbol: '元', slug: 'cny' }, eur: { name: 'Euro', symbol: '€', slug: 'eur' }, inr: { name: 'Indian Rupee', symbol: '₹', slug: 'inr' }, idr: { name: 'Indonesian Rupiah', symbol: 'Rp', slug: 'idr' }, jpy: { name: 'Japanese Yen', symbol: '¥', slug: 'jpy' }, krw: { name: 'Korean Won', symbol: '₩', slug: 'krw' }, nok: { name: 'Norwegian Krone', symbol: 'kr', slug: 'nok' }, rub: { name: 'Russian Ruble', symbol: 'р.', slug: 'rub' }, zar: { name: 'South African Rand', symbol: 'R', slug: 'zar' }, try: { name: 'Turkish Lira', symbol: '₺', slug: 'try' }, vnd: { name: 'Vietnamese đồng', symbol: 'đ', slug: 'vnd' }, }, }; export function isValidCashPrefix(addressString) { // Note that this function validates prefix only // Check for prefix included in currency.prefixes array // For now, validation is handled by converting to bitcoincash: prefix and checksum // and relying on legacy validation methods of bitcoincash: prefix addresses // Also accept an address with no prefix, as some exchanges provide these for (let i = 0; i < currency.prefixes.length; i += 1) { // If the addressString being tested starts with an accepted prefix or no prefix at all if ( addressString.startsWith(currency.prefixes[i] + ':') || !addressString.includes(':') ) { return true; } } return false; } export function isValidTokenPrefix(addressString) { // Check for prefix included in currency.tokenPrefixes array // For now, validation is handled by converting to simpleledger: prefix and checksum // and relying on legacy validation methods of simpleledger: prefix addresses // For token addresses, do not accept an address with no prefix for (let i = 0; i < currency.tokenPrefixes.length; i += 1) { if (addressString.startsWith(currency.tokenPrefixes[i] + ':')) { return true; } } return false; } export function toLegacy(address) { let testedAddress; let legacyAddress; try { if (isValidCashPrefix(address)) { // Prefix-less addresses may be valid, but the cashaddr.decode function used below // will throw an error without a prefix. Hence, must ensure prefix to use that function. const hasPrefix = address.includes(':'); if (!hasPrefix) { testedAddress = currency.legacyPrefix + ':' + address; } else { testedAddress = address; } // Note: an `ecash:` checksum address with no prefix will not be validated by // parseAddress in Send.js // Only handle the case of prefixless address that is valid `bitcoincash:` address const { type, hash } = cashaddr.decode(testedAddress); legacyAddress = cashaddr.encode(currency.legacyPrefix, type, hash); } else { console.log(`Error: ${address} is not a cash address`); throw new Error( 'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array', ); } } catch (err) { return err; } return legacyAddress; } export function parseAddress(BCH, addressString, isToken = false) { // Build return obj const addressInfo = { address: '', isValid: false, queryString: null, amount: null, }; // Parse address string for parameters const paramCheck = addressString.split('?'); let cleanAddress = paramCheck[0]; addressInfo.address = cleanAddress; // Validate address let isValidAddress; try { isValidAddress = BCH.Address.isCashAddress(cleanAddress); // Only accept addresses with ecash: prefix const { prefix } = cashaddr.decode(cleanAddress); // If the address does not have a valid prefix or token prefix if ( (!isToken && !currency.prefixes.includes(prefix)) || (isToken && !currency.tokenPrefixes.includes(prefix)) ) { // then it is not a valid destination address for XEC sends isValidAddress = false; } } catch (err) { isValidAddress = false; } addressInfo.isValid = isValidAddress; // Check for parameters // only the amount param is currently supported let queryString = null; let amount = null; if (paramCheck.length > 1) { queryString = paramCheck[1]; addressInfo.queryString = queryString; const addrParams = new URLSearchParams(queryString); if (addrParams.has('amount')) { // Amount in satoshis try { amount = new BigNumber(parseInt(addrParams.get('amount'))) - .div(1e8) + .div(10 ** currency.cashDecimals) .toString(); } catch (err) { amount = null; } } } addressInfo.amount = amount; return addressInfo; }