diff --git a/web/cashtab/extension/src/components/App.js b/web/cashtab/extension/src/components/App.js --- a/web/cashtab/extension/src/components/App.js +++ b/web/cashtab/extension/src/components/App.js @@ -10,12 +10,14 @@ CaretRightOutlined, SettingFilled, AppstoreAddOutlined, + TeamOutlined, } from '@ant-design/icons'; import Wallet from '@components/Wallet/Wallet'; import Tokens from '@components/Tokens/Tokens'; import Send from '@components/Send/Send'; import SendToken from '@components/Send/SendToken'; import Configure from '@components/Configure/Configure'; +import Social from '@components/Social/Social'; import NotFound from '@components/NotFound'; import CashTab from '@assets/cashtab_xec.png'; import './App.css'; @@ -101,10 +103,10 @@ outline: none; } cursor: pointer; - padding: 24px 12px 12px 12px; - margin: 0 28px; + padding: 24px 6px 12px 12px; + margin: 0 18px; @media (max-width: 475px) { - margin: 0 20px; + margin: 0 10px; } @media (max-width: 420px) { margin: 0 12px; @@ -114,7 +116,7 @@ } background-color: ${props => props.theme.footer.background}; border: none; - font-size: 12px; + font-size: 10px; font-weight: bold; .anticon { display: block; @@ -274,6 +276,13 @@ /> )} /> + + + @@ -306,6 +315,15 @@ Send + + history.push('/social')} + > + + Social + + history.push('/configure')} diff --git a/web/cashtab/package-lock.json b/web/cashtab/package-lock.json --- a/web/cashtab/package-lock.json +++ b/web/cashtab/package-lock.json @@ -5,9 +5,11 @@ "requires": true, "packages": { "": { + "name": "cashtab", "version": "1.0.0", "dependencies": { "@ant-design/icons": "^4.3.0", + "@antd-mobile/pull-to-refresh": "^0.3.2", "@fortawesome/fontawesome-free": "^5.15.1", "@zxing/library": "0.8.0", "antd": "^4.9.3", @@ -153,6 +155,18 @@ "resize-observer-polyfill": "^1.5.0" } }, + "node_modules/@antd-mobile/pull-to-refresh": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@antd-mobile/pull-to-refresh/-/pull-to-refresh-0.3.2.tgz", + "integrity": "sha512-DteUXYysOHZ4qat0TBWxSXSPli4EK1xt/1K15iuDkXYtpluHEDVQtSn+fJotl4lx1wkl0G6gpRq41rS0iWXI2Q==", + "dependencies": { + "@babel/runtime": "^7.4.5", + "classnames": "^2.2.6" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/@babel/code-frame": { "version": "7.12.13", "license": "MIT", @@ -1892,7 +1906,6 @@ "jest-resolve": "^26.6.2", "jest-util": "^26.6.2", "jest-worker": "^26.6.2", - "node-notifier": "^8.0.0", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", @@ -3528,7 +3541,6 @@ "version": "0.8.0", "license": "MIT", "dependencies": { - "text-encoding": "^0.6.4", "ts-custom-error": "^2.2.1" }, "optionalDependencies": { @@ -3908,7 +3920,6 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dependencies": { - "graceful-fs": "^4.1.6", "universalify": "^2.0.0" }, "optionalDependencies": { @@ -3961,7 +3972,6 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dependencies": { - "graceful-fs": "^4.1.6", "universalify": "^2.0.0" }, "optionalDependencies": { @@ -5874,7 +5884,6 @@ "dependencies": { "anymatch": "~3.1.1", "braces": "~3.0.2", - "fsevents": "~2.3.1", "glob-parent": "~5.1.0", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -8503,8 +8512,7 @@ "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" + "optionator": "^0.8.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -10732,7 +10740,6 @@ "minimist": "^1.2.5", "neo-async": "^2.6.0", "source-map": "^0.6.1", - "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" }, "bin": { @@ -13062,8 +13069,7 @@ "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" + "optionator": "^0.8.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -13202,7 +13208,6 @@ "@jest/types": "^24.9.0", "anymatch": "^2.0.0", "fb-watchman": "^2.0.0", - "fsevents": "^1.2.7", "graceful-fs": "^4.1.15", "invariant": "^2.2.4", "jest-serializer": "^24.9.0", @@ -13712,7 +13717,6 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", - "fsevents": "^2.1.2", "graceful-fs": "^4.2.4", "jest-regex-util": "^26.0.0", "jest-serializer": "^26.6.2", @@ -15039,9 +15043,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.6" - }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -15184,13 +15185,6 @@ "license": "Apache-2.0", "dependencies": { "copy-anything": "^2.0.1", - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "native-request": "^1.0.5", - "source-map": "~0.6.0", "tslib": "^1.10.0" }, "bin": { @@ -27048,10 +27042,8 @@ "dev": true, "license": "MIT", "dependencies": { - "chokidar": "^3.4.1", "graceful-fs": "^4.1.2", - "neo-async": "^2.5.0", - "watchpack-chokidar2": "^2.0.1" + "neo-async": "^2.5.0" }, "optionalDependencies": { "chokidar": "^3.4.1", @@ -27140,7 +27132,6 @@ "anymatch": "^2.0.0", "async-each": "^1.0.1", "braces": "^2.3.2", - "fsevents": "^1.2.7", "glob-parent": "^3.1.0", "inherits": "^2.0.3", "is-binary-path": "^1.0.0", @@ -27598,7 +27589,6 @@ "anymatch": "^2.0.0", "async-each": "^1.0.1", "braces": "^2.3.2", - "fsevents": "^1.2.7", "glob-parent": "^3.1.0", "inherits": "^2.0.3", "is-binary-path": "^1.0.0", @@ -29331,6 +29321,15 @@ "resize-observer-polyfill": "^1.5.0" } }, + "@antd-mobile/pull-to-refresh": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@antd-mobile/pull-to-refresh/-/pull-to-refresh-0.3.2.tgz", + "integrity": "sha512-DteUXYysOHZ4qat0TBWxSXSPli4EK1xt/1K15iuDkXYtpluHEDVQtSn+fJotl4lx1wkl0G6gpRq41rS0iWXI2Q==", + "requires": { + "@babel/runtime": "^7.4.5", + "classnames": "^2.2.6" + } + }, "@babel/code-frame": { "version": "7.12.13", "requires": { diff --git a/web/cashtab/package.json b/web/cashtab/package.json --- a/web/cashtab/package.json +++ b/web/cashtab/package.json @@ -5,6 +5,7 @@ "homepage": "https://cashtab.com/", "dependencies": { "@ant-design/icons": "^4.3.0", + "@antd-mobile/pull-to-refresh": "^0.3.2", "@fortawesome/fontawesome-free": "^5.15.1", "@zxing/library": "0.8.0", "antd": "^4.9.3", diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js --- a/web/cashtab/src/components/App.js +++ b/web/cashtab/src/components/App.js @@ -10,12 +10,14 @@ CaretRightOutlined, SettingFilled, AppstoreAddOutlined, + TeamOutlined, } from '@ant-design/icons'; import Wallet from '@components/Wallet/Wallet'; import Tokens from '@components/Tokens/Tokens'; import Send from '@components/Send/Send'; import SendToken from '@components/Send/SendToken'; import Configure from '@components/Configure/Configure'; +import Social from '@components/Social/Social'; import NotFound from '@components/NotFound'; import CashTab from '@assets/cashtab_xec.png'; import './App.css'; @@ -106,10 +108,10 @@ outline: none; } cursor: pointer; - padding: 24px 12px 12px 12px; - margin: 0 28px; + padding: 24px 6px 12px 12px; + margin: 0 18px; @media (max-width: 475px) { - margin: 0 20px; + margin: 0 10px; } @media (max-width: 420px) { margin: 0 12px; @@ -119,7 +121,7 @@ } background-color: ${props => props.theme.footer.background}; border: none; - font-size: 10.5px; + font-size: 10px; font-weight: bold; .anticon { display: block; @@ -306,6 +308,13 @@ /> )} /> + + + @@ -339,6 +348,15 @@ Send + + history.push('/social')} + > + + Social + + history.push('/configure')} diff --git a/web/cashtab/src/components/Common/Notifications.js b/web/cashtab/src/components/Common/Notifications.js --- a/web/cashtab/src/components/Common/Notifications.js +++ b/web/cashtab/src/components/Common/Notifications.js @@ -43,6 +43,25 @@ }); }; +// Success Notifications: +const postSocialMsgNotification = link => { + const notificationStyle = getDeviceNotificationStyle(); + notification.success({ + message: 'Success', + description: ( + + + Social post submitted successfully. Click to view in block + explorer. + + + ), + duration: currency.notificationDurationShort, + icon: , + style: notificationStyle, + }); +}; + const createTokenNotification = link => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ @@ -179,4 +198,5 @@ eTokenReceivedNotification, errorNotification, messageSignedNotification, + postSocialMsgNotification, }; diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -15,6 +15,9 @@ cashDecimals: 2, blockExplorerUrl: 'https://explorer.bitcoinabc.org', tokenExplorerUrl: 'https://explorer.be.cash', + chronikClientUrl: 'https://chronik.be.cash/xec', + cashtabSocialHubAddr: + 'bitcoincash:qz305assdkl639zvem0d3g5vj5wtpyzr55t0qhm676', // ecash:qz305assdkl639zvem0d3g5vj5wtpyzr55jz5uqqcd blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'eToken', tokenTicker: 'eToken', @@ -23,6 +26,8 @@ tokenPrefixes: ['etoken'], tokenIconsUrl: 'https://etoken-icons.s3.us-west-2.amazonaws.com', txHistoryCount: 10, + socialHistoryCount: 20, + socialFeedRefreshTimeout: 10000, //10 seconds xecApiBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, notificationDurationShort: 3, @@ -36,9 +41,15 @@ eToken: '534c5000', cashtab: '00746162', cashtabEncrypted: '65746162', + cashtabSocial: '73746162', }, encryptedMsgCharLimit: 94, unencryptedMsgCharLimit: 160, + social: { + postMessagePrefixHex: '706f7374', // 'post' + setNamePrefixHex: '6e616d65', // 'name' + setPhotoPrefixHex: '70696373', // 'pics' + }, }, settingsValidation: { fiatCurrency: [ diff --git a/web/cashtab/src/components/Social/Social.js b/web/cashtab/src/components/Social/Social.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Social/Social.js @@ -0,0 +1,322 @@ +import React, { useState, useEffect, createElement } from 'react'; +import PropTypes from 'prop-types'; +import { WalletContext } from '@utils/context'; +import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; +import { + Form, + Modal, + Spin, + Button, + Comment, + Tooltip, + Avatar, + Divider, + Drawer, + Input, +} from 'antd'; +import moment from 'moment'; +import useBCH from '@hooks/useBCH'; +import { getWalletState } from '@utils/cashMethods'; +import { UserOutlined } from '@ant-design/icons'; +import styled, { css } from 'styled-components'; +import { + postSocialMsgNotification, + errorNotification, +} from '@components/Common/Notifications'; +import { currency } from '@components/Common/Ticker.js'; +import localforage from 'localforage'; +import PullToRefresh from '@antd-mobile/pull-to-refresh'; +import BigNumber from 'bignumber.js'; +import { isValidSocialMsg } from '@utils/validation'; + +const SocialMessage = styled.div` + text-align: left; +`; + +// Note jestBCH is only used for unit tests; BCHJS must be mocked for jest +const Social = ({ jestBCH, passLoadingStatus }) => { + const ContextValue = React.useContext(WalletContext); + const { wallet, cashtabSettings } = ContextValue; + const walletState = getWalletState(wallet); + const { balances, slpBalancesAndUtxos } = walletState; + + const [bchObj, setBchObj] = useState(false); + + useEffect(async () => { + // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); + const BCH = jestBCH ? jestBCH : getBCH(); + + // set the BCH instance to state, for other functions to reference + setBchObj(BCH); + + // initial check for updates to the social feed + if (await hasSocialFeedChanged(BCH)) { + // onchain feed has changed + await refreshSocialFeed(BCH, true); + } else { + // onchain feed has not unchanged + const localFeed = await localforage.getItem( + 'localSocialAddrTxFeed', + ); + setSocialFeedHistory(localFeed); + } + + // subsequent checks for updates to the social feed every 10 seconds + const interval = setInterval(async () => { + console.log('Polling onchain social feed for updates.'); + if (await hasSocialFeedChanged(BCH)) { + // onchain feed has changed + console.log('New social feed updates found.'); + await refreshSocialFeed(BCH, true); + } else { + // onchain feed has not unchanged + const localFeed = await localforage.getItem( + 'localSocialAddrTxFeed', + ); + setSocialFeedHistory(localFeed); + } + }, currency.socialFeedRefreshTimeout); + + return () => clearInterval(interval); // clearing interval to prevent memory leaks. + }, []); + + const [socialMessage, setSocialMessage] = useState(''); + const [isValidSocialMessage, setIsValidSocialMessage] = useState(null); + const [socialFeedHistory, setSocialFeedHistory] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + // identity management drawer state variables + const [identityDrawVisible, setIdentityDrawVisible] = useState(false); + + const showIdentityDrawer = () => { + setIdentityDrawVisible(true); + }; + + const onIdentityDrawerClose = () => { + setIdentityDrawVisible(false); + }; + + const { + getBCH, + sendSocialTx, + retrieveSocialFeed, + getSocialFeedHistoryCount, + } = useBCH(); + + const postSocialMsg = async () => { + try { + const link = await sendSocialTx( + bchObj, + wallet, + slpBalancesAndUtxos.nonSlpUtxos, + currency.defaultFee, + currency.opReturn.social.postMessagePrefixHex, + socialMessage.toString(), + ); + postSocialMsgNotification(link); + setSocialMessage(''); + } catch (err) { + console.log('Social.postSocialMsg() error: ' + err); + errorNotification(err.error, err.message, 'Posting social message'); + } + }; + + // checks whether the social feed onchain has changed + // by comparing the total tx history count from onchain vs local storage + // if onchain count > local storage count, then trigger api call to refresh social feed + const hasSocialFeedChanged = async BCH => { + let onchainSocialAddrTxCount; + let localSocialAddrTxCount; + + try { + onchainSocialAddrTxCount = await getSocialFeedHistoryCount(BCH); + localSocialAddrTxCount = await localforage.getItem( + 'localSocialAddrTxCount', + ); + + if (!localSocialAddrTxCount) { + return true; + } + + if ( + new BigNumber(onchainSocialAddrTxCount).isGreaterThan( + new BigNumber(localSocialAddrTxCount.c), + ) + ) { + return true; + } else { + return false; + } + } catch (err) { + console.log('useBCH.hasSocialFeedChanged() error: ' + err); + errorNotification( + err.error, + err.message, + 'Polling for onchain social feed updates', + ); + } + }; + + const refreshSocialFeed = async (BCH, onLoad) => { + let socialHistoryObjArray; + let onchainTxHistoryCount; + let bchInstance; + + if (onLoad) { + bchInstance = BCH; + } else { + bchInstance = bchObj; + } + + try { + // retrieve the social feed array to state and localstorage + socialHistoryObjArray = await retrieveSocialFeed(bchInstance); + setSocialFeedHistory(socialHistoryObjArray); + await localforage.setItem( + 'localSocialAddrTxFeed', + socialHistoryObjArray, + ); + + // retrieve latest onchain social feed address tx history count to local storage + onchainTxHistoryCount = await getSocialFeedHistoryCount( + bchInstance, + ); + await localforage.setItem( + 'localSocialAddrTxCount', + new BigNumber(onchainTxHistoryCount), + ); + } catch (err) { + console.log('Social.refreshSocialFeed() error: ' + err); + errorNotification(err.error, err.message, 'Refreshing social feed'); + } + }; + + const handleSocialPostChange = e => { + const { value } = e.target; + setSocialMessage(value); + setIsValidSocialMessage(isValidSocialMsg(value)); + }; + + return ( + <> + {/* Identity Management drawer*/} +   + } + onClick={showIdentityDrawer} + /> + +

+ Profile Photo:{' '} + } /> +

+

+ Name: TBC{' '} +

+
+ {/* end of Identity Management drawer*/} +
+
+ +
+ + handleSocialPostChange(e)} + required={true} + placeholder="What's up?" + value={socialMessage} + /> + + +
+
+ Cashtab Socials + {/* Social Feed History */} + <> + refreshSocialFeed(bchObj)} + className="socialFeedRefreshPull" + direction="down" + refreshing={refreshing} + indicator={{ + activate: ' ', + deactivate: ' ', + release: , + finish: ' ', + }} + damping={150} + > +
+ {socialFeedHistory.map((socialPost, index) => ( + {socialPost.posterAddress}} + avatar={ + } + /> + } + content={ + + {socialPost.message} + + } + datetime={ + + {socialPost.time} + + } + /> + ))} +
+
+ + + ); +}; + +Social.defaultProps = { + passLoadingStatus: status => { + console.log(status); + }, +}; + +Social.propTypes = { + jestBCH: PropTypes.object, + passLoadingStatus: PropTypes.func, +}; + +export default Social; diff --git a/web/cashtab/src/components/Social/__tests__/Social.test.js b/web/cashtab/src/components/Social/__tests__/Social.test.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Social/__tests__/Social.test.js @@ -0,0 +1,113 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { ThemeProvider } from 'styled-components'; +import { theme } from '@assets/styles/theme'; +import Social from '@components/Social/Social'; +import BCHJS from '@psf/bch-js'; +import { + walletWithBalancesAndTokens, + walletWithBalancesMock, + walletWithoutBalancesMock, + walletWithBalancesAndTokensWithCorrectState, +} from '../../Wallet/__mocks__/walletAndBalancesMock'; +import { BrowserRouter as Router } from 'react-router-dom'; + +let realUseContext; +let useContextMock; + +beforeEach(() => { + realUseContext = React.useContext; + useContextMock = React.useContext = jest.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}); + +afterEach(() => { + React.useContext = realUseContext; +}); + +test('Wallet without XEC balance', () => { + useContextMock.mockReturnValue(walletWithoutBalancesMock); + const testBCH = new BCHJS(); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with XEC balances', () => { + useContextMock.mockReturnValue(walletWithBalancesMock); + const testBCH = new BCHJS(); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with XEC balances and tokens', () => { + useContextMock.mockReturnValue(walletWithBalancesAndTokens); + const testBCH = new BCHJS(); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with XEC balances and tokens and state field', () => { + useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState); + const testBCH = new BCHJS(); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Without wallet defined', () => { + useContextMock.mockReturnValue({ + wallet: {}, + balances: { totalBalance: 0 }, + loading: false, + }); + const testBCH = new BCHJS(); + const component = renderer.create( + + + + + , + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/web/cashtab/src/components/Social/__tests__/__snapshots__/Social.test.js.snap b/web/cashtab/src/components/Social/__tests__/__snapshots__/Social.test.js.snap new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Social/__tests__/__snapshots__/Social.test.js.snap @@ -0,0 +1,661 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP @generated + +exports[`Wallet with XEC balances 1`] = ` +Array [ + " ", + + + + + , +
, +
, +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
, +
+ + Cashtab Socials + +
, +
+
+
+
+ +
+
+
+
+
+
+
, +] +`; + +exports[`Wallet with XEC balances and tokens 1`] = ` +Array [ + " ", + + + + + , +
, +
, +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
, +
+ + Cashtab Socials + +
, +
+
+
+
+ +
+
+
+
+
+
+
, +] +`; + +exports[`Wallet with XEC balances and tokens and state field 1`] = ` +Array [ + " ", + + + + + , +
, +
, +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
, +
+ + Cashtab Socials + +
, +
+
+
+
+ +
+
+
+
+
+
+
, +] +`; + +exports[`Wallet without XEC balance 1`] = ` +Array [ + " ", + + + + + , +
, +
, +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
, +
+ + Cashtab Socials + +
, +
+
+
+
+ +
+
+
+
+
+
+
, +] +`; + +exports[`Without wallet defined 1`] = ` +Array [ + " ", + + + + + , +
, +
, +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
, +
+ + Cashtab Socials + +
, +
+
+
+
+ +
+
+
+
+
+
+
, +] +`; diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -661,4 +661,150 @@ expectedPubKey, ); }); + + it('send social tx correctly with a Post action', async () => { + const { sendSocialTx } = useBCH(); + const BCH = new BCHJS(); + const { expectedTxId, utxos, wallet } = sendBCHMock; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + expect( + await sendSocialTx( + BCH, + wallet, + utxos, + currency.defaultFee, + currency.opReturn.social.postMessagePrefixHex, + '6e6577207465737420323030', // new test 200 + ), + ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); + }); + + it('send social tx correctly with a Set Name action', async () => { + const { sendSocialTx } = useBCH(); + const BCH = new BCHJS(); + const { expectedTxId, utxos, wallet } = sendBCHMock; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + expect( + await sendSocialTx( + BCH, + wallet, + utxos, + currency.defaultFee, + currency.opReturn.social.setNamePrefixHex, + null, + '4a6f6579', // joey + ), + ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); + }); + + it('send social tx correctly with a Set Photo Url action', async () => { + const { sendSocialTx } = useBCH(); + const BCH = new BCHJS(); + const { expectedTxId, utxos, wallet } = sendBCHMock; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + expect( + await sendSocialTx( + BCH, + wallet, + utxos, + currency.defaultFee, + currency.opReturn.social.setPhotoPrefixHex, + null, + null, + '68747470733a2f2f6a6f657363686d6f652e696f2f6170692f76312f72616e646f6d', // https://joeschmoe.io/api/v1/random + ), + ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); + }); + + it('send social tx correctly rejects an invalid action prefix', async () => { + const { sendSocialTx } = useBCH(); + const BCH = new BCHJS(); + const { utxos, wallet } = sendBCHMock; + const expectedError = + 'useBCH.sendSocialTx() error: invalid social tx action prefix'; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + const err = new Error(expectedError); + throw err; + }); + + const invalidSocialActionTx = sendSocialTx( + BCH, + wallet, + utxos, + currency.defaultFee, + 'this action prefix does not exist', + '626974636f696e636173683a717138343539377630657376356875386d726e32346439647070653678336b6e66636d366d3563737939', + '68747470733a2f2f6a6f657363686d6f652e696f2f6170692f76312f72616e646f6d', // https://joeschmoe.io/api/v1/random + ); + await expect(invalidSocialActionTx).rejects.toThrow( + new Error(expectedError), + ); + }); + + it('send social tx correctly rejects a valid post action but with an invalid post message', async () => { + const { sendSocialTx } = useBCH(); + const BCH = new BCHJS(); + const { utxos, wallet } = sendBCHMock; + const expectedError = + 'useBCH.sendSocialPost() error: invalid social POST tx action'; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + const err = new Error(expectedError); + throw err; + }); + + const invalidSocialActionTx = sendSocialTx( + BCH, + wallet, + utxos, + currency.defaultFee, + currency.opReturn.social.postMessagePrefixHex, + ' ', // empty string as the post message content + ); + await expect(invalidSocialActionTx).rejects.toThrow( + new Error(expectedError), + ); + }); + + it('send social tx correctly rejects a valid Set Name action but with an invalid name value', async () => { + const { sendSocialTx } = useBCH(); + const BCH = new BCHJS(); + const { utxos, wallet } = sendBCHMock; + const expectedError = + 'useBCH.sendSocialPost() error: invalid social name update tx action'; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockImplementation(async () => { + const err = new Error(expectedError); + throw err; + }); + + const invalidSocialActionTx = sendSocialTx( + BCH, + wallet, + utxos, + currency.defaultFee, + currency.opReturn.social.setNamePrefixHex, + null, + null, // null name value + ); + await expect(invalidSocialActionTx).rejects.toThrow( + new Error(expectedError), + ); + }); }); diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -13,10 +13,12 @@ convertToEncryptStruct, getPublicKey, parseOpReturn, + convertToEcashPrefix, } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; import ecies from 'ecies-lite'; import wif from 'wif'; +import { isValidSocialOutputTx } from '@utils/validation'; export default function useBCH() { const SEND_BCH_ERRORS = { @@ -205,6 +207,11 @@ parsedOpReturnArray[1], ); } + } else if ( + txType === + currency.opReturn.appPrefixesHex.cashtabSocial + ) { + // ignore the social posts messages for now } else if ( txType === currency.opReturn.appPrefixesHex.cashtabEncrypted @@ -1480,6 +1487,392 @@ } }; + const sendSocialTx = async ( + BCH, + wallet, + utxos, + feeInSatsPerByte, + socialAction, + socialPostMsg, + newName, + newProfilePicUrl, + ) => { + try { + 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 = currency.dustSats; + + let script; + let data; + + // if this is a social post action + if ( + socialAction === currency.opReturn.social.postMessagePrefixHex + ) { + if ( + socialPostMsg && + typeof socialPostMsg !== 'undefined' && + socialPostMsg.trim() !== '' + ) { + // only build the script if the social post content is not empty + script = [ + BCH.Script.opcodes.OP_RETURN, // 6a + Buffer.from( + currency.opReturn.appPrefixesHex.cashtabSocial, + 'hex', + ), // 73746162 + Buffer.from( + currency.opReturn.social.postMessagePrefixHex, + 'hex', + ), // 706f7374 + Buffer.from(wallet.Path1899.cashAddress), // bch:.... + // Additional prefix hex here for replies to parent posts + Buffer.from(socialPostMsg), + ]; + data = BCH.Script.encode(script); + transactionBuilder.addOutput(data, 0); + } else { + throw new Error( + 'useBCH.sendSocialPost() error: invalid social POST tx action', + ); + } + + // if this is setting the user's social profile name + } else if ( + socialAction === currency.opReturn.social.setNamePrefixHex + ) { + if ( + newName && + typeof newName !== 'undefined' && + newName.trim() !== '' + ) { + // only build the script if the profile name is not empty + script = [ + BCH.Script.opcodes.OP_RETURN, // 6a + Buffer.from( + currency.opReturn.appPrefixesHex.cashtabSocial, + 'hex', + ), // 73746162 + Buffer.from( + currency.opReturn.social.setNamePrefixHex, + 'hex', + ), // 6e616d65 + Buffer.from(wallet.Path1899.cashAddress), // bch:.... + // set profile name + Buffer.from(newName), + ]; + data = BCH.Script.encode(script); + transactionBuilder.addOutput(data, 0); + } else { + throw new Error( + 'useBCH.sendSocialPost() error: invalid social name update tx action', + ); + } + + // if this is setting the user's social profile avatar url + } else if ( + socialAction === currency.opReturn.social.setPhotoPrefixHex + ) { + if ( + newProfilePicUrl && + typeof newProfilePicUrl !== 'undefined' && + newProfilePicUrl.trim() !== '' + ) { + // only build the script if the social profile photo url is not empty + script = [ + BCH.Script.opcodes.OP_RETURN, // 6a + Buffer.from( + currency.opReturn.appPrefixesHex.cashtabSocial, + 'hex', + ), // 73746162 + Buffer.from( + currency.opReturn.social.setPhotoPrefixHex, + 'hex', + ), // 70696373 + Buffer.from(wallet.Path1899.cashAddress), // bch:.... + // set profile photo url + Buffer.from(newProfilePicUrl), + ]; + data = BCH.Script.encode(script); + transactionBuilder.addOutput(data, 0); + } else { + throw new Error( + 'useBCH.sendSocialTx() error: invalid social photo update tx action', + ); + } + } else { + throw new Error( + 'useBCH.sendSocialTx() error: invalid social tx action prefix', + ); + } + + let originalAmount = new BigNumber(0); + let txFee = 0; + for (let i = 0; i < utxos.length; i++) { + const utxo = utxos[i]; + originalAmount = originalAmount.plus(utxo.value); + const vout = utxo.vout; + const txid = utxo.txid; + // add input with txid and index of vout + transactionBuilder.addInput(txid, vout); + + inputUtxos.push(utxo); + txFee = calcFee(BCH, inputUtxos, 2, feeInSatsPerByte); + + if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { + break; + } + } + + // Get change address from sending utxos + // fall back to what is stored in wallet + let REMAINDER_ADDR; + + // Validate address + let isValidChangeAddress; + try { + REMAINDER_ADDR = inputUtxos[0].address; + isValidChangeAddress = + BCH.Address.isCashAddress(REMAINDER_ADDR); + } catch (err) { + isValidChangeAddress = false; + } + if (!isValidChangeAddress) { + REMAINDER_ADDR = wallet.Path1899.cashAddress; + } + + // amount to send back to the remainder address. + const remainder = originalAmount.minus(satoshisToSend).minus(txFee); + + if (remainder.lt(0)) { + const error = new Error(`Insufficient funds`); + error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; + throw error; + } + + transactionBuilder.addOutput( + BCH.Address.toCashAddress(currency.cashtabSocialHubAddr), + parseInt(satoshisToSend), + ); + + if (remainder.gte(new BigNumber(currency.dustSats))) { + transactionBuilder.addOutput( + REMAINDER_ADDR, + parseInt(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.value, + ); + } + + // 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 (process.env.REACT_APP_NETWORK === `mainnet`) { + link = `${currency.blockExplorerUrl}/tx/${txidStr}`; + } else { + link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; + } + 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; + } + }; + + // retrieves the total tx count for the social feed address + const getSocialFeedHistoryCount = async BCH => { + if (!BCH) { + throw new Error( + 'useBCH.getSocialFeedHistoryCount(): invalid BCH instance.', + ); + } + + try { + const socialFeedTxHistory = await getTxHistory( + BCH, + currency.cashtabSocialHubAddr, + ); + return socialFeedTxHistory.length; + } catch (err) { + console.log('useBCH.getSocialFeedHistoryCount() err: ' + err); + } + }; + + // Retrieves the social feed tx history and parses to extract the data for rendering + // The structure of a social tx OP_RETURN output is: + // [OP_RETURN] + // index 0 - [Cashtab Social Prefix Hex] + // index 1 - [Social Action Prefix Hex] + // index 2 - [Poster Address] + // index 3 - [Message content] || [New Profile Name] || [New Profile Photo Url] + const retrieveSocialFeed = async BCH => { + if (!BCH) { + throw new Error( + 'useBCH.retrieveSocialFeed(): invalid BCH instance.', + ); + } + + let socialFeedTxHistory, sortedSocialFeedTxHistory; + let sortedSocialFeedTxIdArray = []; + const parsedSocialFeedArray = []; + + try { + // retrieve the full social feed history + socialFeedTxHistory = await getTxHistory( + BCH, + currency.cashtabSocialHubAddr, + ); + + // sort the feed (incl. unconfirmed txs) in descending order + sortedSocialFeedTxHistory = await BCH.Electrumx.sortAllTxs( + socialFeedTxHistory, + ); + + if (!sortedSocialFeedTxHistory) { + return parsedSocialFeedArray; // return empty array if tx history retrieval call fails + } + // instead of making separate api calls to retrieve the underlying tx data, + // create an array of tx id and pass it to the endpoint in one single batch + // additional array size limitation is applied via currency.socialHistoryCount + for ( + let k = 0; + k < sortedSocialFeedTxHistory.length && + k < currency.socialHistoryCount; + k++ + ) { + sortedSocialFeedTxIdArray.push( + sortedSocialFeedTxHistory[k].tx_hash, + ); + } + + // retrieve transaction details for the most recent txs + const socialFeedTxData = await BCH.Electrumx.txData( + sortedSocialFeedTxIdArray, + ); + + // parse the transaction details for rendering + for (let i = 0; i < socialFeedTxData.transactions.length; i++) { + const parsedSocialTx = {}; + const thisTxDetails = socialFeedTxData.transactions[i].details; + + if ( + !Object.keys(thisTxDetails.vout[0].scriptPubKey).includes( + 'addresses', + ) + ) { + // this is an OP_RETURN output + + // parse the OP_RETURN hex + let hex = thisTxDetails.vout[0].scriptPubKey.hex; + let parsedOpReturnArray = parseOpReturn(hex); + if (!parsedOpReturnArray) { + console.log( + 'useBCH.retrieveSocialFeed.parseOpReturn() error: parsed array is empty', + ); + break; + } + + // only collect the output data if the prefix indicates a social tx + // this excludes eToken and normal messaging txs + if (isValidSocialOutputTx(parsedOpReturnArray)) { + const socialPostTime = thisTxDetails.blocktime; + const actionType = parsedOpReturnArray[1]; + + // if this is a social Post action + if ( + actionType === + currency.opReturn.social.postMessagePrefixHex + ) { + let posterAddress; + + const posterBchAddress = Buffer.from( + parsedOpReturnArray[2], + 'hex', + ).toString(); + if (BCH.Address.isCashAddress(posterBchAddress)) { + posterAddress = + convertToEcashPrefix(posterBchAddress); + } else { + posterAddress = ''; + } + const socialPostMsg = Buffer.from( + parsedOpReturnArray[3], + 'hex', + ); + + parsedSocialTx.posterAddress = + posterAddress.substring(0, 12) + + '.....' + + posterAddress.substring( + posterAddress.length - 6, + posterAddress.length, + ); + parsedSocialTx.message = socialPostMsg.toString(); + parsedSocialTx.time = new Date( + socialPostTime, + ).toString(); + parsedSocialFeedArray.push(parsedSocialTx); + + // this is a Set Name action + } else if ( + actionType === + currency.opReturn.social.setNamePrefixHex + ) { + // placeholder to handle frontend action to update the profile name + } else if ( + actionType === + currency.opReturn.social.setPhotoPrefixHex + ) { + // placeholder to handle frontend action to update the profile photo url + } + } + } + } + } catch (err) { + console.log('useBCH.retrieveSocialFeed() error: ' + err); + throw err; + } + + return parsedSocialFeedArray; + }; + const getBCH = (apiIndex = 0) => { let ConstructedSlpWallet; @@ -1509,5 +1902,8 @@ getTokenStats, handleEncryptedOpReturn, getRecipientPublicKey, + sendSocialTx, + retrieveSocialFeed, + getSocialFeedHistoryCount, }; } diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -12,6 +12,8 @@ isValidEtokenAddress, isValidXecSendAmount, isValidSendToMany, + isValidSocialOutputTx, + isValidSocialMsg, } from '../validation'; import { currency } from '@components/Common/Ticker.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; @@ -366,4 +368,50 @@ const testXecSendAmount = undefined; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); + it(`isValidSocialOutputTx accepts a valid Social action output hex`, () => { + const parsedOpReturnArray = []; + parsedOpReturnArray[0] = currency.opReturn.appPrefixesHex.cashtabSocial; + parsedOpReturnArray[1] = currency.opReturn.social.postMessagePrefixHex; + parsedOpReturnArray[2] = + '626974636f696e636173683a717138343539377630657376356875386d726e32346439647070653678336b6e66636d366d3563737939'; + parsedOpReturnArray[3] = '6e6577207465737420323030'; + expect(isValidSocialOutputTx(parsedOpReturnArray)).toBe(true); + }); + it(`isValidSocialOutputTx rejects a valid Cashtab Messaging output hex`, () => { + const parsedOpReturnArray = []; + parsedOpReturnArray[0] = currency.opReturn.appPrefixesHex.cashtab; + parsedOpReturnArray[1] = + '626974636f696e636173683a717138343539377630657376356875386d726e32346439647070653678336b6e66636d366d3563737939'; + expect(isValidSocialOutputTx(parsedOpReturnArray)).toBe(false); + }); + it(`isValidSocialOutputTx rejects a null input`, () => { + const parsedOpReturnArray = []; + expect(isValidSocialOutputTx(parsedOpReturnArray)).toBe(false); + }); + it(`isValidSocialMsg accepts a valid social message input`, () => { + const socialMsgInput = 'this is a valid message'; + expect(isValidSocialMsg(socialMsgInput)).toBe(true); + }); + it(`isValidSocialMsg rejects null input`, () => { + const socialMsgInput = null; + expect(isValidSocialMsg(socialMsgInput)).toBe(false); + }); + it(`isValidSocialMsg rejects an empty string input`, () => { + const socialMsgInput = ' '; + expect(isValidSocialMsg(socialMsgInput)).toBe(false); + }); + it(`isValidSocialMsg rejects a string input over 148 characters`, () => { + const socialMsgInput = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + expect(isValidSocialMsg(socialMsgInput)).toBe(false); + }); + it(`isValidSocialMsg accepts a string input at exactly 148 characters`, () => { + const socialMsgInput = + 'testing the 160 char limit OUHDF iusahf oaisjf aoisjdf oiasjflwej filj sldifj alsidjf lasidjf lasidjf laisdjf laisdjlasid fliasdjliasdjliasdjlisjd f'; + expect(isValidSocialMsg(socialMsgInput)).toBe(true); + }); + it(`isValidSocialMsg rejects a non-string input`, () => { + const socialMsgInput = 3278628767382; + expect(isValidSocialMsg(socialMsgInput)).toBe(false); + }); }); diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -57,6 +57,18 @@ ) { // add the Cashtab encryption prefix to array resultArray[i] = currency.opReturn.appPrefixesHex.cashtabEncrypted; + } else if ( + i === 0 && + message === currency.opReturn.appPrefixesHex.cashtabSocial + ) { + // add the Cashtab social prefix to array + resultArray[i] = currency.opReturn.appPrefixesHex.cashtabSocial; + } else if ( + i === 1 && + message === currency.opReturn.social.postMessage + ) { + // add the Cashtab social 'post' action prefix to array + resultArray[i] = currency.opReturn.social.postMessage; } else { // this is either an external message or a subsequent cashtab message loop to extract the message resultArray[i] = message; diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -3,6 +3,31 @@ import { fromSmallestDenomination } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; +export const isValidSocialMsg = inputString => { + return ( + typeof inputString === 'string' && + inputString.length > 0 && + inputString.trim().length > 0 && + inputString.length < 149 + ); +}; + +// Validate whether a tx's output is a Cashtab Social action +export const isValidSocialOutputTx = parsedOpReturnArray => { + if ( + parsedOpReturnArray[0] && + parsedOpReturnArray[0] === + currency.opReturn.appPrefixesHex.cashtabSocial && + parsedOpReturnArray[1] && + parsedOpReturnArray[2] && + parsedOpReturnArray[3] + ) { + return true; + } else { + return false; + } +}; + // Validate cash amount export const shouldRejectAmountInput = ( cashAmount,