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 @@ -15,6 +15,8 @@ import Tokens from '@components/Tokens/Tokens'; import Send from '@components/Send/Send'; import SendToken from '@components/Send/SendToken'; +import SendNFT from '@components/Send/SendNFT'; +import SendChildNFT from '@components/Send/SendChildNFT'; import Configure from '@components/Configure/Configure'; import NotFound from '@components/NotFound'; import CashTab from '@assets/cashtab_xec.png'; @@ -330,6 +332,28 @@ /> )} /> + ( + + )} + /> + ( + + )} + /> @@ -352,7 +376,7 @@ onClick={() => history.push('/tokens')} > - eTokens + eTokens & NFTs props.theme.forms.error} !important; `; 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 --- a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap +++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap @@ -2,7 +2,7 @@ exports[`Configure with a wallet 1`] = `

[

[ { + const { wallet, apiError } = React.useContext(WalletContext); + const walletState = getWalletState(wallet); + const { tokens, slpBalancesAndUtxos } = walletState; + const token = tokens.find(token => token.tokenId === tokenId); + + const [tokenStats, setTokenStats] = useState(null); + const [queryStringText, setQueryStringText] = useState(null); + const [sendTokenAddressError, setSendTokenAddressError] = useState(false); + 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 { getBCH, getRestUrl, sendChildNFT, getTokenStats } = useBCH(); + + // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); + const BCH = jestBCH ? jestBCH : getBCH(); + + // Fetch token stats if you do not have them and API did not return an error + if (tokenStats === null) { + getTokenStats(BCH, tokenId).then( + result => { + setTokenStats(result); + }, + err => { + console.log(`Error getting token stats: ${err}`); + }, + ); + } + + 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('SendChildNFT', 'Send', tokenId); + + passLoadingStatus(true); + const { address, value } = formData; + + // Clear params from address + let cleanAddress = address.split('?')[0]; + + // Convert to simpleledger prefix if etoken + cleanAddress = convertEtokenToSimpleledger(cleanAddress); + + try { + const link = await sendChildNFT(BCH, wallet, slpBalancesAndUtxos, { + tokenId: tokenId, + tokenReceiverAddress: cleanAddress, + amount: value, + }); + + sendTokenNotification(link); + } catch (e) { + passLoadingStatus(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); + } + errorNotification(e, message, 'Sending eToken'); + } + } + + 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 handleTokenAddressChange = e => { + const { value, name } = e.target; + // validate for token address + // validate for parameters + // show warning that query strings are not supported + + let error = false; + let addressString = value; + + const addressInfo = parseAddress(BCH, addressString, true); + /* + Model + + addressInfo = + { + address: '', + isValid: false, + queryString: '', + amount: null, + }; + */ + + const { address, isValid, queryString } = addressInfo; + + // If query string, + // Show an alert that only amount and currency.ticker are supported + setQueryStringText(queryString); + + // Is this valid address? + if (!isValid) { + error = 'Address is not a valid etoken: address'; + // If valid address but token format + } else if (!isValidTokenPrefix(address)) { + error = `Cashtab only supports sending to ${currency.tokenPrefixes[0]} prefixed addresses`; + } + setSendTokenAddressError(error); + + 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 + + passLoadingStatus(false); + }, [token]); + + return ( + <> + {!token && } + + {token && ( + <> + + + + +
+ + handleTokenAddressChange({ + target: { + name: 'address', + value: result, + }, + }) + } + inputProps={{ + placeholder: `${currency.tokenTicker} Address`, + name: 'address', + onChange: e => + handleTokenAddressChange(e), + required: true, + value: formData.address, + }} + /> + + } + /> + ) : ( + {`identicon + ), + suffix: token.info.tokenTicker, + onChange: e => handleSlpAmountChange(e), + required: true, + value: formData.value, + }} + /> +
+ {apiError || + sendTokenAmountError || + sendTokenAddressError ? ( + <> + + Send {token.info.tokenName} + + + ) : ( + submit()}> + Send {token.info.tokenName} + + )} +
+ + {queryStringText && ( + + )} + {apiError && } + + {tokenStats !== null && ( + + +
+
+
+ ---NFT Image goes here--- +
+
+
+
+
+
+
+
+
+
+ + {token.tokenId} + + {tokenStats && ( + <> + + {tokenStats.documentUri} + + + {tokenStats.timestampUnix !== + null + ? new Date( + tokenStats.timestampUnix * + 1000, + ).toLocaleDateString() + : 'Just now (Genesis tx confirming)'} + + + {tokenStats.containsBaton + ? 'No' + : 'Yes'} + + + {tokenStats.initialTokenQty.toLocaleString()} + + + {tokenStats.totalBurned.toLocaleString()} + + + {tokenStats.totalMinted.toLocaleString()} + + + {tokenStats.circulatingSupply.toLocaleString()} + + + )} +
+ )} + +
+ + )} + + ); +}; + +/* +passLoadingStatus must receive a default prop that is a function +in order to pass the rendering unit test in SendGroupNFT.test.js + +status => {console.log(status)} is an arbitrary stub function +*/ + +SendChildNFT.defaultProps = { + passLoadingStatus: status => { + console.log(status); + }, +}; + +SendChildNFT.propTypes = { + tokenId: PropTypes.string, + jestBCH: PropTypes.object, + passLoadingStatus: PropTypes.func, +}; + +export default SendChildNFT; diff --git a/web/cashtab/src/components/Send/SendNFT.js b/web/cashtab/src/components/Send/SendNFT.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Send/SendNFT.js @@ -0,0 +1,429 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { WalletContext } from '@utils/context'; +import { Form, message, Row, Col, Alert, Descriptions } from 'antd'; +import PrimaryButton, { + SecondaryButton, +} from '@components/Common/PrimaryButton'; +import { + FormItemWithMaxAddon, + FormItemWithQRCodeAddon, +} from '@components/Common/EnhancedInputs'; +import useBCH from '@hooks/useBCH'; +import BalanceHeader from '@components/Common/BalanceHeader'; +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, + parseAddress, + isValidTokenPrefix, +} from '@components/Common/Ticker.js'; +import { Event } from '@utils/GoogleAnalytics'; +import { + getWalletState, + convertEtokenToSimpleledger, +} from '@utils/cashMethods'; +import ApiError from '@components/Common/ApiError'; +import { + sendTokenNotification, + errorNotification, +} from '@components/Common/Notifications'; + +const SendNFT = ({ tokenId, jestBCH, passLoadingStatus }) => { + const { wallet, apiError } = React.useContext(WalletContext); + const walletState = getWalletState(wallet); + const { tokens, slpBalancesAndUtxos } = walletState; + const token = tokens.find(token => token.tokenId === tokenId); + + const [tokenStats, setTokenStats] = useState(null); + const [queryStringText, setQueryStringText] = useState(null); + const [sendTokenAddressError, setSendTokenAddressError] = useState(false); + 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 { getBCH, getRestUrl, sendGroupNFT, getTokenStats } = useBCH(); + + // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); + const BCH = jestBCH ? jestBCH : getBCH(); + + // Fetch token stats if you do not have them and API did not return an error + if (tokenStats === null) { + getTokenStats(BCH, tokenId).then( + result => { + setTokenStats(result); + }, + err => { + console.log(`Error getting token stats: ${err}`); + }, + ); + } + + 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('SendNFT', 'Send', tokenId); + + passLoadingStatus(true); + const { address, value } = formData; + + // Clear params from address + let cleanAddress = address.split('?')[0]; + + // Convert to simpleledger prefix if etoken + cleanAddress = convertEtokenToSimpleledger(cleanAddress); + + try { + const link = await sendGroupNFT(BCH, wallet, slpBalancesAndUtxos, { + tokenId: tokenId, + tokenReceiverAddress: cleanAddress, + amount: value, + }); + + sendTokenNotification(link); + } catch (e) { + passLoadingStatus(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); + } + errorNotification(e, message, 'Sending eToken'); + } + } + + 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 handleTokenAddressChange = e => { + const { value, name } = e.target; + // validate for token address + // validate for parameters + // show warning that query strings are not supported + + let error = false; + let addressString = value; + + const addressInfo = parseAddress(BCH, addressString, true); + /* + Model + + addressInfo = + { + address: '', + isValid: false, + queryString: '', + amount: null, + }; + */ + + const { address, isValid, queryString } = addressInfo; + + // If query string, + // Show an alert that only amount and currency.ticker are supported + setQueryStringText(queryString); + + // Is this valid address? + if (!isValid) { + error = 'Address is not a valid etoken: address'; + // If valid address but token format + } else if (!isValidTokenPrefix(address)) { + error = `Cashtab only supports sending to ${currency.tokenPrefixes[0]} prefixed addresses`; + } + setSendTokenAddressError(error); + + 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 + + passLoadingStatus(false); + }, [token]); + + return ( + <> + {!token && } + + {token && ( + <> + + + + +
+ + handleTokenAddressChange({ + target: { + name: 'address', + value: result, + }, + }) + } + inputProps={{ + placeholder: `${currency.tokenTicker} Address`, + name: 'address', + onChange: e => + handleTokenAddressChange(e), + required: true, + value: formData.address, + }} + /> + + } + /> + ) : ( + {`identicon + ), + suffix: token.info.tokenTicker, + onChange: e => handleSlpAmountChange(e), + required: true, + value: formData.value, + }} + /> +
+ {apiError || + sendTokenAmountError || + sendTokenAddressError ? ( + <> + + Send {token.info.tokenName} + + + ) : ( + submit()}> + Send {token.info.tokenName} + + )} +
+ + {queryStringText && ( + + )} + {apiError && } + + {tokenStats !== null && ( + + + {token.tokenId} + + {tokenStats && ( + <> + + {tokenStats.documentUri} + + + {tokenStats.timestampUnix !== + null + ? new Date( + tokenStats.timestampUnix * + 1000, + ).toLocaleDateString() + : 'Just now (Genesis tx confirming)'} + + + {tokenStats.containsBaton + ? 'No' + : 'Yes'} + + + {tokenStats.initialTokenQty.toLocaleString()} + + + {tokenStats.totalBurned.toLocaleString()} + + + {tokenStats.totalMinted.toLocaleString()} + + + {tokenStats.circulatingSupply.toLocaleString()} + + + )} + + )} + +
+ + )} + + ); +}; + +/* +passLoadingStatus must receive a default prop that is a function +in order to pass the rendering unit test in SendGroupNFT.test.js + +status => {console.log(status)} is an arbitrary stub function +*/ + +SendNFT.defaultProps = { + passLoadingStatus: status => { + console.log(status); + }, +}; + +SendNFT.propTypes = { + tokenId: PropTypes.string, + jestBCH: PropTypes.object, + passLoadingStatus: PropTypes.func, +}; + +export default SendNFT; diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap @@ -314,7 +314,7 @@

= @@ -653,7 +653,7 @@

= @@ -999,7 +999,7 @@
= @@ -1339,7 +1339,7 @@
= @@ -1678,7 +1678,7 @@
= diff --git a/web/cashtab/src/components/Tokens/CreateChildNftForm.js b/web/cashtab/src/components/Tokens/CreateChildNftForm.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Tokens/CreateChildNftForm.js @@ -0,0 +1,334 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; +import { TokenCollapse } from '@components/Common/StyledCollapse'; +import { currency } from '@components/Common/Ticker.js'; +import { WalletContext } from '@utils/context'; +import { + isValidChildNFTName, + isValidChildNFTTicker, + isValidChildNFTGroupId, + isValidChildNFTDocumentUrl, +} from '@utils/validation'; +import { PlusSquareOutlined } from '@ant-design/icons'; +import { SmartButton } from '@components/Common/PrimaryButton'; +import { Collapse, Form, Input, Modal } from 'antd'; +const { Panel } = Collapse; +import { ChildNFTParamLabel } from '@components/Common/Atoms'; +import { + createTokenNotification, + errorNotification, +} from '@components/Common/Notifications'; + +const CreateChildNftForm = ({ + BCH, + getRestUrl, + createToken, + disabled, + passLoadingStatus, +}) => { + const { wallet } = React.useContext(WalletContext); + + // New child NFT Name + const [newChildNFTName, setNewChildNFTName] = useState(''); + const [newChildNFTNameIsValid, setNewChildNFTNameIsValid] = useState(null); + const handleNewChildNFTNameInput = e => { + const { value } = e.target; + // validation + setNewChildNFTNameIsValid(isValidChildNFTName(value)); + setNewChildNFTName(value); + }; + + // New child NFT Ticker + const [newChildNFTTicker, setNewChildNFTTicker] = useState(''); + const [newChildNFTTickerIsValid, setNewChildNFTTickerIsValid] = + useState(null); + const handleNewChildNFTTickerInput = e => { + const { value } = e.target; + // validation + setNewChildNFTTickerIsValid(isValidChildNFTTicker(value)); + setNewChildNFTTicker(value); + }; + + // New child NFT Batons + const [newChildNFTGroupId, setNewChildNFTGroupId] = useState(0); + const [newChildNFTGroupIdIsValid, setNewChildNFTGroupIdIsValid] = + useState(true); + const handleNewChildNFTGroupIdInput = e => { + const { value } = e.target; + // validation + setNewChildNFTGroupIdIsValid(isValidChildNFTGroupId(value)); + // Also validate the supply here if it has not yet been set + /* + if (newChildNFTInitialQtyIsValid !== null) { + setNewTokenInitialQtyIsValid( + isValidChildNFTInitialQty(value, newChildNFTGroupId), + ); + } + */ + setNewChildNFTGroupId(value); + }; + + // New New child NFT document URL + const [newChildNFTDocumentUrl, setNewChildNFTDocumentUrl] = useState(''); + // Start with this as true, field is not required + const [newChildNFTDocumentUrlIsValid, setNewChildNFTDocumentUrlIsValid] = + useState(true); + + const handleNewTokenDocumentUrlInput = e => { + const { value } = e.target; + // validation + setNewChildNFTDocumentUrlIsValid(isValidChildNFTDocumentUrl(value)); + setNewChildNFTDocumentUrl(value); + }; + + // New New child NFT fixed supply + // Only allow creation of fixed supply tokens until Minting support is added + + // New New child NFT document hash + // Do not include this; questionable value to casual users and requires significant complication + + // Only enable CreateChildNftForm button if all form entries are valid + let childNFTDataIsValid = + newChildNFTNameIsValid && + newChildNFTTickerIsValid && + newChildNFTGroupIdIsValid && + newChildNFTDocumentUrlIsValid; + + // Modal settings + const [showConfirmCreateChildNFT, setShowConfirmCreateChildNFT] = + useState(false); + + const createPreviewedChildNFT = async () => { + passLoadingStatus(true); + // If data is for some reason not valid here, bail out + if (!childNFTDataIsValid) { + return; + } + + // data must be valid and user reviewed to get here + const configObj = { + name: newChildNFTName, + ticker: newChildNFTTicker, + documentUrl: + newChildNFTDocumentUrl === '' + ? 'https://cashtab.com/' + : newChildNFTDocumentUrl, + tokenId: newChildNFTGroupId, + initialQty: '1', + documentHash: '', + }; + + // create New child NFT with data in state fields + try { + const link = await createToken( + BCH, + wallet, + currency.defaultFee, + configObj, + ); + createTokenNotification(link); + } catch (e) { + // Set loading to false here as well, as balance may not change depending on where error occured in try loop + passLoadingStatus(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); + } + errorNotification(e, message, 'Creating eToken'); + } + // Hide the modal + setShowConfirmCreateChildNFT(false); + // Stop spinner + passLoadingStatus(false); + }; + return ( + <> + setShowConfirmCreateChildNFT(false)} + > + Group NFT ID:{' '} + {newChildNFTGroupId} +
+ Name: {newChildNFTName} +
+ Ticker:{' '} + {newChildNFTTicker} +
+ Document URL:{' '} + {newChildNFTDocumentUrl === '' + ? 'https://cashtab.com/' + : newChildNFTDocumentUrl} +
+
+ <> + + + +
+ + + handleNewChildNFTGroupIdInput(e) + } + /> + + + + handleNewChildNFTNameInput(e) + } + /> + + + + handleNewChildNFTTickerInput(e) + } + /> + + + + handleNewTokenDocumentUrlInput(e) + } + /> + +
+
+ setShowConfirmCreateChildNFT(true)} + disabled={!childNFTDataIsValid} + > + +  Create Child NFT + +
+
+ + + ); +}; + +/* +passLoadingStatus must receive a default prop that is a function +in order to pass the rendering unit test in CreateChildNftForm.test.js + +status => {console.log(status)} is an arbitrary stub function +*/ + +CreateChildNftForm.defaultProps = { + passLoadingStatus: status => { + console.log(status); + }, +}; + +CreateChildNftForm.propTypes = { + BCH: PropTypes.object, + getRestUrl: PropTypes.func, + createChildNFTToken: PropTypes.func, + disabled: PropTypes.bool, + passLoadingStatus: PropTypes.func, +}; + +export default CreateChildNftForm; diff --git a/web/cashtab/src/components/Tokens/CreateGroupNftForm.js b/web/cashtab/src/components/Tokens/CreateGroupNftForm.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Tokens/CreateGroupNftForm.js @@ -0,0 +1,335 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; +import { TokenCollapse } from '@components/Common/StyledCollapse'; +import { currency } from '@components/Common/Ticker.js'; +import { WalletContext } from '@utils/context'; +import { + isValidGroupNFTName, + isValidGroupNFTTicker, + isValidGroupNFTBatons, + isValidGroupNFTDocumentUrl, +} from '@utils/validation'; +import { PlusSquareOutlined } from '@ant-design/icons'; +import { SmartButton } from '@components/Common/PrimaryButton'; +import { Collapse, Form, Input, Modal } from 'antd'; +const { Panel } = Collapse; +import { GroupNFTParamLabel } from '@components/Common/Atoms'; +import { + createTokenNotification, + errorNotification, +} from '@components/Common/Notifications'; + +const CreateGroupNftForm = ({ + BCH, + getRestUrl, + createToken, + disabled, + passLoadingStatus, +}) => { + const { wallet } = React.useContext(WalletContext); + + // New Group NFT Name + const [newGroupNFTName, setNewGroupNFTName] = useState(''); + const [newGroupNFTNameIsValid, setNewGroupNFTNameIsValid] = useState(null); + const handleNewGroupNFTNameInput = e => { + const { value } = e.target; + // validation + setNewGroupNFTNameIsValid(isValidGroupNFTName(value)); + setNewGroupNFTName(value); + }; + + // New Group NFT Ticker + const [newGroupNFTTicker, setNewGroupNFTTicker] = useState(''); + const [newGroupNFTTickerIsValid, setNewGroupNFTTickerIsValid] = + useState(null); + const handleNewGroupNFTTickerInput = e => { + const { value } = e.target; + // validation + setNewGroupNFTTickerIsValid(isValidGroupNFTTicker(value)); + setNewGroupNFTTicker(value); + }; + + // New Group NFT Batons + const [newGroupNFTBatons, setNewGroupNFTBatons] = useState(0); + const [newGroupNFTBatonsIsValid, setNewGroupNFTBatonsIsValid] = + useState(true); + const handleNewGroupNFTBatonsInput = e => { + const { value } = e.target; + // validation + setNewGroupNFTBatonsIsValid(isValidGroupNFTBatons(value)); + // Also validate the supply here if it has not yet been set + /* + if (newGroupNFTInitialQtyIsValid !== null) { + setNewTokenInitialQtyIsValid( + isValidGroupNFTInitialQty(value, newGroupNFTBatons), + ); + } + */ + setNewGroupNFTBatons(value); + }; + + // New New Group NFT document URL + const [newGroupNFTDocumentUrl, setNewGroupNFTDocumentUrl] = useState(''); + // Start with this as true, field is not required + const [newGroupNFTDocumentUrlIsValid, setNewGroupNFTDocumentUrlIsValid] = + useState(true); + + const handleNewTokenDocumentUrlInput = e => { + const { value } = e.target; + // validation + setNewGroupNFTDocumentUrlIsValid(isValidGroupNFTDocumentUrl(value)); + setNewGroupNFTDocumentUrl(value); + }; + + // New New Group NFT fixed supply + // Only allow creation of fixed supply tokens until Minting support is added + + // New New Group NFT document hash + // Do not include this; questionable value to casual users and requires significant complication + + // Only enable CreateGroupNftForm button if all form entries are valid + let groupNFTDataIsValid = + newGroupNFTNameIsValid && + newGroupNFTTickerIsValid && + newGroupNFTBatonsIsValid && + newGroupNFTDocumentUrlIsValid; + + // Modal settings + const [showConfirmCreateGroupNFT, setShowConfirmCreateGroupNFT] = + useState(false); + + const createPreviewedGroupNFT = async () => { + passLoadingStatus(true); + // If data is for some reason not valid here, bail out + if (!groupNFTDataIsValid) { + return; + } + + // data must be valid and user reviewed to get here + const configObj = { + name: newGroupNFTName, + ticker: newGroupNFTTicker, + documentUrl: + newGroupNFTDocumentUrl === '' + ? 'https://cashtab.com/' + : newGroupNFTDocumentUrl, + mintBatonVout: newGroupNFTBatons, + initialQty: '1', + documentHash: '', + }; + + // create New Group NFT with data in state fields + try { + const link = await createToken( + BCH, + wallet, + currency.defaultFee, + configObj, + ); + createTokenNotification(link); + } catch (e) { + // Set loading to false here as well, as balance may not change depending on where error occured in try loop + passLoadingStatus(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); + } + errorNotification(e, message, 'Creating eToken'); + } + // Hide the modal + setShowConfirmCreateGroupNFT(false); + // Stop spinner + passLoadingStatus(false); + }; + return ( + <> + setShowConfirmCreateGroupNFT(false)} + > + Name: {newGroupNFTName} +
+ Ticker:{' '} + {newGroupNFTTicker} +
+ Batons:{' '} + {newGroupNFTBatons} +
+ Document URL:{' '} + {newGroupNFTDocumentUrl === '' + ? 'https://cashtab.com/' + : newGroupNFTDocumentUrl} +
+
+ <> + + + +
+ + + handleNewGroupNFTNameInput(e) + } + /> + + + + handleNewGroupNFTTickerInput(e) + } + /> + + + + handleNewGroupNFTBatonsInput(e) + } + /> + + + + handleNewTokenDocumentUrlInput(e) + } + /> + +
+
+ setShowConfirmCreateGroupNFT(true)} + disabled={!groupNFTDataIsValid} + > + +  Create Group NFT + +
+
+ + + ); +}; + +/* +passLoadingStatus must receive a default prop that is a function +in order to pass the rendering unit test in CreateGroupNftForm.test.js + +status => {console.log(status)} is an arbitrary stub function +*/ + +CreateGroupNftForm.defaultProps = { + passLoadingStatus: status => { + console.log(status); + }, +}; + +CreateGroupNftForm.propTypes = { + BCH: PropTypes.object, + getRestUrl: PropTypes.func, + createGroupNFTToken: PropTypes.func, + disabled: PropTypes.bool, + passLoadingStatus: PropTypes.func, +}; + +export default CreateGroupNftForm; diff --git a/web/cashtab/src/components/Tokens/Tokens.js b/web/cashtab/src/components/Tokens/Tokens.js --- a/web/cashtab/src/components/Tokens/Tokens.js +++ b/web/cashtab/src/components/Tokens/Tokens.js @@ -3,13 +3,25 @@ import { WalletContext } from '@utils/context'; import { fromSmallestDenomination, getWalletState } from '@utils/cashMethods'; import CreateTokenForm from '@components/Tokens/CreateTokenForm'; +import CreateGroupNftForm from '@components/Tokens/CreateGroupNftForm'; +import CreateChildNftForm from '@components/Tokens/CreateChildNftForm'; import { currency } from '@components/Common/Ticker.js'; import TokenList from '@components/Wallet/TokenList'; +import GroupNFTList from '@components/Wallet/GroupNFTList'; +import ChildNFTList from '@components/Wallet/ChildNFTList'; import useBCH from '@hooks/useBCH'; import BalanceHeader from '@components/Common/BalanceHeader'; import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, AlertMsg } from '@components/Common/Atoms'; import ApiError from '@components/Common/ApiError'; +import styled from 'styled-components'; + +const NftSpacer = styled.div` + height: 1px; + width: 100%; + background-color: ${props => props.theme.wallet.borders.color}; + margin: 60px 0 50px; +`; const Tokens = ({ jestBCH, passLoadingStatus }) => { /* @@ -31,7 +43,8 @@ const walletState = getWalletState(wallet); const { balances, tokens } = walletState; - const { getBCH, getRestUrl, createToken } = useBCH(); + const { getBCH, getRestUrl, createToken, createGroupNFT, createChildNFT } = + useBCH(); // Support using locally installed bchjs for unit tests const BCH = jestBCH ? jestBCH : getBCH(); @@ -68,6 +81,25 @@ disabled={balances.totalBalanceInSatoshis < currency.dustSats} passLoadingStatus={passLoadingStatus} /> + + + + + + eTokens + {balances.totalBalanceInSatoshis < currency.dustSats && ( You need at least{' '} @@ -100,6 +132,73 @@ ) : ( <>No {currency.tokenTicker} tokens in this wallet )} + + Group NFTs + + {balances.totalBalanceInSatoshis < currency.dustSats && ( + + You need at least{' '} + {fromSmallestDenomination(currency.dustSats).toString()}{' '} + {currency.ticker} ( + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].symbol + } ` + : '$ '} + {( + fromSmallestDenomination(currency.dustSats).toString() * + fiatPrice + ).toFixed(4)}{' '} + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} + ) to create a Group NFT + + )} + {tokens && tokens.length > 0 ? ( + <> + + + ) : ( + <>No {currency.tokenTicker} tokens in this wallet + )} + + Child NFTs + {balances.totalBalanceInSatoshis < currency.dustSats && ( + + You need at least{' '} + {fromSmallestDenomination(currency.dustSats).toString()}{' '} + {currency.ticker} ( + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].symbol + } ` + : '$ '} + {( + fromSmallestDenomination(currency.dustSats).toString() * + fiatPrice + ).toFixed(4)}{' '} + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} + ) to create a Child NFT + + )} + {tokens && tokens.length > 0 ? ( + <> + + + ) : ( + <>No {currency.tokenTicker} tokens in this wallet + )} ); }; diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap --- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap +++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap @@ -59,8 +59,99 @@
, +
+
+
+ + + + Create Group NFT +
+
+
, +
+
+
+ + + + Create Child NFT +
+
+
, +
+ eTokens +
,

You need at least @@ -77,6 +168,52 @@ "No ", "eToken", " tokens in this wallet", +

+ Group NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Group NFT +

, + "No ", + "eToken", + " tokens in this wallet", +
+ Child NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Child NFT +

, + "No ", + "eToken", + " tokens in this wallet", ] `; @@ -90,11 +227,515 @@ in your wallet to create tokens. ,
+ 0 + + XEC +
, +
+
+
+ + + + Create eToken +
+
+
, +
+
+
+ + + + Create Group NFT +
+
+
, +
+
+
+ + + + Create Child NFT +
+
+
, +
+ eTokens +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a token +

, + "No ", + "eToken", + " tokens in this wallet", +
+ Group NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Group NFT +

, + "No ", + "eToken", + " tokens in this wallet", +
+ Child NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Child NFT +

, + "No ", + "eToken", + " tokens in this wallet", +] +`; + +exports[`Wallet with BCH balances and tokens and state field 1`] = ` +Array [ +
+ 0.06 + + XEC +
, +
+ $ + NaN + + USD +
, +
+
+
+ + + + Create eToken +
+
+
, +
+
+
+ + + + Create Group NFT +
+
+
, +
+
+
+ + + + Create Child NFT +
+
+
, +
+ eTokens +
, +
, +
+ Group NFTs +
, +
, +
+ Child NFTs +
, +
, +] +`; + +exports[`Wallet without BCH balance 1`] = ` +Array [ +
+ You need some + XEC + in your wallet to create tokens. +
, +
+ 0 + + XEC +
, +
+
+
+ + + + Create eToken +
+
+
, +
- 0 - - XEC +
+
+ + + + Create Group NFT +
+
,
- Create eToken + Create Child NFT
, +
+ eTokens +
,

You need at least @@ -157,28 +803,73 @@ "No ", "eToken", " tokens in this wallet", +

+ Group NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Group NFT +

, + "No ", + "eToken", + " tokens in this wallet", +
+ Child NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Child NFT +

, + "No ", + "eToken", + " tokens in this wallet", ] `; -exports[`Wallet with BCH balances and tokens and state field 1`] = ` +exports[`Without wallet defined 1`] = ` Array [
- 0.06 - + You need some XEC + in your wallet to create tokens.
,
- $ - NaN + 0 - USD + XEC
,
, - , -] -`; - -exports[`Wallet without BCH balance 1`] = ` -Array [ -
- You need some - XEC - in your wallet to create tokens. -
, -
- 0 - - XEC -
,
- Create eToken + Create Group NFT
, -

- You need at least - - 5.5 - - XEC - ( - $ - NaN - - USD - ) to create a token -

, - "No ", - "eToken", - " tokens in this wallet", -] -`; - -exports[`Without wallet defined 1`] = ` -Array [ -
- You need some - XEC - in your wallet to create tokens. -
, -
- 0 - - XEC -
,
- Create eToken + Create Child NFT
, +
+ eTokens +
,

You need at least @@ -415,5 +1020,51 @@ "No ", "eToken", " tokens in this wallet", +

+ Group NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Group NFT +

, + "No ", + "eToken", + " tokens in this wallet", +
+ Child NFTs +
, +

+ You need at least + + 5.5 + + XEC + ( + $ + NaN + + USD + ) to create a Child NFT +

, + "No ", + "eToken", + " tokens in this wallet", ] `; diff --git a/web/cashtab/src/components/Wallet/ChildNFTList.js b/web/cashtab/src/components/Wallet/ChildNFTList.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Wallet/ChildNFTList.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TokenListItem from './TokenListItem'; +import { Link } from 'react-router-dom'; +import { formatBalance } from '@utils/cashMethods'; + +const ChildNFTList = ({ tokens }) => { + return ( +
+ {tokens + .filter(token => token.isChildNFT) + .map(token => ( + + + + ))} +
+ ); +}; + +ChildNFTList.propTypes = { + tokens: PropTypes.array, +}; + +export default ChildNFTList; diff --git a/web/cashtab/src/components/Wallet/GroupNFTList.js b/web/cashtab/src/components/Wallet/GroupNFTList.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Wallet/GroupNFTList.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TokenListItem from './TokenListItem'; +import { Link } from 'react-router-dom'; +import { formatBalance } from '@utils/cashMethods'; + +const GroupNFTList = ({ tokens }) => { + return ( +
+ {tokens + .filter(token => token.isGroupNFT) + .map(token => ( + + + + ))} +
+ ); +}; + +GroupNFTList.propTypes = { + tokens: PropTypes.array, +}; + +export default GroupNFTList; diff --git a/web/cashtab/src/components/Wallet/TokenList.js b/web/cashtab/src/components/Wallet/TokenList.js --- a/web/cashtab/src/components/Wallet/TokenList.js +++ b/web/cashtab/src/components/Wallet/TokenList.js @@ -7,15 +7,20 @@ const TokenList = ({ tokens }) => { return (
- {tokens.map(token => ( - - - - ))} + {tokens + .filter(token => !token.isGroupNFT && !token.isChildNFT) + .map(token => ( + + + + ))}
); }; 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 --- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap +++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap @@ -119,16 +119,16 @@ ,
XEC
eToken @@ -256,16 +256,16 @@
,
XEC
eToken @@ -375,16 +375,16 @@
,
XEC
eToken @@ -512,16 +512,16 @@
,
XEC
eToken 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 @@ -521,6 +521,12 @@ } else { token.hasBaton = false; } + if (slpUtxo.tokenType === 129) { + token.isGroupNFT = true; + } + if (slpUtxo.tokenType === 65) { + token.isChildNFT = true; + } tokensById[slpUtxo.tokenId] = token; } @@ -670,6 +676,272 @@ } }; + const createGroupNFT = async (BCH, wallet, feeInSatsPerByte, configObj) => { + try { + // Throw error if wallet does not have utxo set in state + if (!isValidStoredWallet(wallet)) { + const walletError = new Error(`Invalid wallet`); + throw walletError; + } + const utxos = wallet.state.slpBalancesAndUtxos.nonSlpUtxos; + + const CREATION_ADDR = wallet.Path1899.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'); + + let originalAmount = new BigNumber(0); + + let txFee = 0; + for (let i = 0; i < utxos.length; i++) { + const utxo = utxos[i]; + originalAmount = originalAmount.plus(new BigNumber(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, 3, feeInSatsPerByte); + + if ( + originalAmount + .minus(new BigNumber(currency.etokenSats)) + .minus(new BigNumber(txFee)) + .gte(0) + ) { + break; + } + } + + // amount to send back to the remainder address. + const remainder = originalAmount + .minus(new BigNumber(currency.etokenSats)) + .minus(new BigNumber(txFee)); + + if (remainder.lt(0)) { + const error = new Error(`Insufficient funds`); + error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; + throw error; + } + + // Generate the OP_RETURN entry for a Group NFT transaction. + const script = BCH.SLP.NFT1.newNFTGroupOpReturn(configObj); + // OP_RETURN needs to be the first output in the transaction. + transactionBuilder.addOutput(script, 0); + + // add output w/ address and amount to send + transactionBuilder.addOutput(CREATION_ADDR, currency.etokenSats); + + // Send change to own address + if (remainder.gte(new BigNumber(currency.etokenSats))) { + transactionBuilder.addOutput( + CREATION_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.tokenExplorerUrl}/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(`useBCH createGroupNFT() error: `, err); + throw err; + } + }; + + const createChildNFT = async (BCH, wallet, feeInSatsPerByte, configObj) => { + try { + // Throw error if wallet does not have utxo set in state + if (!isValidStoredWallet(wallet)) { + const walletError = new Error(`Invalid wallet`); + throw walletError; + } + // Handle error of user having no BCH + if (wallet.state.slpBalancesAndUtxos.nonSlpUtxos.length === 0) { + throw new Error( + `You need some ${currency.ticker} to send ${currency.tokenTicker}`, + ); + } + + let tokenUtxos = wallet.state.slpBalancesAndUtxos.slpUtxos; + const bchUtxos = wallet.state.slpBalancesAndUtxos.nonSlpUtxos; + + if (bchUtxos.length === 0) { + throw new Error( + 'Wallet does not have a XEC UTXO to pay miner fees.', + ); + } + + // Filter out the token UTXOs that match the user-provided token ID + // and contain the minting baton. + tokenUtxos = tokenUtxos.filter((utxo, index) => { + if ( + utxo && // UTXO is associated with a token. + utxo.tokenId === configObj.tokenId && // UTXO matches the token ID. + utxo.utxoType === 'token' // UTXO is not a minting baton. + ) { + return true; + } + }); + + if (tokenUtxos.length === 0) { + throw new Error( + 'No token UTXOs for the specified Group NFT could be found.', + ); + } + + // Get the biggest UTXO to pay for the transaction. + const utxo = wallet.state.slpBalancesAndUtxos.nonSlpUtxos.reduce( + (previous, current) => + previous.value > current.value ? previous : current, + ); + + const CREATION_ADDR = wallet.Path1899.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'); + + let originalAmount = new BigNumber(0); + + originalAmount = utxo.value; + const vout = utxo.tx_pos; + const txid = utxo.tx_hash; + + // add the NFT Group UTXO as an input. This NFT Group token must be burned + // to create a Child NFT, as per the spec. + transactionBuilder.addInput( + tokenUtxos[0].tx_hash, + tokenUtxos[0].tx_pos, + ); + + // add input with txid and index of vout + transactionBuilder.addInput(txid, vout); + + const txFee = 550; + + // amount to send back to the remainder address. + const remainder = originalAmount - txFee; + + // Generate the OP_RETURN entry for a Group NFT transaction. + const script = + BCH.SLP.NFT1.generateNFTChildGenesisOpReturn(configObj); + // OP_RETURN needs to be the first output in the transaction. + transactionBuilder.addOutput(script, 0); + + // add output w/ address and amount to send + transactionBuilder.addOutput(CREATION_ADDR, 546); + + let redeemScript; + + // Sign the Token UTXO for the NFT Group token that will be burned in this + // transaction. + transactionBuilder.sign( + 0, + BCH.ECPair.fromWIF(utxo.wif), + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + 546, + ); + + // Sign the input for the UTXO paying for the TX. + transactionBuilder.sign( + 1, + BCH.ECPair.fromWIF(utxo.wif), + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + originalAmount, + ); + + // 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.tokenExplorerUrl}/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(`useBCH createChildNFT() error: `, err); + throw err; + } + }; + // No unit tests for this function as it is only an API wrapper // Return false if do not get a valid response const getTokenStats = async (BCH, tokenId) => { @@ -860,6 +1132,356 @@ return link; }; + const sendGroupNFT = async ( + BCH, + wallet, + slpBalancesAndUtxos, + { tokenId, amount, tokenReceiverAddress }, + ) => { + // Handle error of user having no BCH + if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { + throw new Error( + `You need some ${currency.ticker} to send ${currency.tokenTicker}`, + ); + } + const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( + (previous, current) => + previous.value > current.value ? previous : current, + ); + + 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. + utxo.tokenType === 129 // UTXO is for an NFT Group + ) { + 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); + + let tokenUtxosBeingSpent = []; + for (let i = 0; i < tokenUtxos.length; i++) { + finalTokenAmountSent = finalTokenAmountSent.plus( + new BigNumber(tokenUtxos[i].tokenQty), + ); + transactionBuilder.addInput( + tokenUtxos[i].tx_hash, + tokenUtxos[i].tx_pos, + ); + tokenUtxosBeingSpent.push(tokenUtxos[i]); + if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { + break; + } + } + + const slpSendObj = BCH.SLP.NFT1.generateNFTGroupSendOpReturn( + 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), + currency.etokenSats, + ); + + // Return any token change back to the sender. + if (slpSendObj.outputs > 1) { + // Change goes back to where slp utxo came from + transactionBuilder.addOutput( + BCH.SLP.Address.toLegacyAddress( + tokenUtxosBeingSpent[0].address, + ), + currency.etokenSats, + ); + } + + // 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 - currency.etokenSats * 2; + if (remainder < 1) { + throw new Error('Selected UTXO does not have enough satoshis'); + } + + // Last output: send the BCH change back to the wallet. + + // Send it back from whence it came + 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, wallet.Path1899]; + 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. + + 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 sendChildNFT = async ( + BCH, + wallet, + slpBalancesAndUtxos, + { tokenId, amount, tokenReceiverAddress }, + ) => { + // Handle error of user having no BCH + if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { + throw new Error( + `You need some ${currency.ticker} to send ${currency.tokenTicker}`, + ); + } + const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( + (previous, current) => + previous.value > current.value ? previous : current, + ); + + 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. + utxo.tokenType === 65 // UTXO is for a child NFT Group + ) { + 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); + + let tokenUtxosBeingSpent = []; + for (let i = 0; i < tokenUtxos.length; i++) { + finalTokenAmountSent = finalTokenAmountSent.plus( + new BigNumber(tokenUtxos[i].tokenQty), + ); + transactionBuilder.addInput( + tokenUtxos[i].tx_hash, + tokenUtxos[i].tx_pos, + ); + tokenUtxosBeingSpent.push(tokenUtxos[i]); + if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { + break; + } + } + + const slpSendObj = BCH.SLP.NFT1.generateNFTChildSendOpReturn( + 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), + currency.etokenSats, + ); + + // Return any token change back to the sender. + if (slpSendObj.outputs > 1) { + // Change goes back to where slp utxo came from + transactionBuilder.addOutput( + BCH.SLP.Address.toLegacyAddress( + tokenUtxosBeingSpent[0].address, + ), + currency.etokenSats, + ); + } + + // 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 - currency.etokenSats * 2; + if (remainder < 1) { + throw new Error('Selected UTXO does not have enough satoshis'); + } + + // Last output: send the BCH change back to the wallet. + + // Send it back from whence it came + 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, wallet.Path1899]; + 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. + + 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 sendBch = async ( BCH, wallet, @@ -1035,7 +1657,11 @@ getRestUrl, sendBch, sendToken, + sendGroupNFT, + sendChildNFT, createToken, + createGroupNFT, + createChildNFT, getTokenStats, }; } 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 @@ -93,6 +93,78 @@ ); }; +export const isValidGroupNFTName = nftName => { + return ( + typeof nftName === 'string' && nftName.length > 0 && nftName.length < 68 + ); +}; + +export const isValidGroupNFTTicker = nftTicker => { + return ( + typeof nftTicker === 'string' && + nftTicker.length > 0 && + nftTicker.length < 13 + ); +}; + +export const isValidGroupNFTBatons = nftBatons => { + return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes( + nftBatons, + ); +}; + +/* +export const isValidGroupNFTInitialQty = (nftInitialQty) => { + return ( + nftInitialQty >= 0 && + nftInitialQty < 1000000 + ); +}; +*/ + +export const isValidGroupNFTDocumentUrl = nftDocumentUrl => { + return ( + typeof nftDocumentUrl === 'string' && + nftDocumentUrl.length >= 0 && + nftDocumentUrl.length < 68 + ); +}; + +export const isValidChildNFTName = nftName => { + return ( + typeof nftName === 'string' && nftName.length > 0 && nftName.length < 68 + ); +}; + +export const isValidChildNFTTicker = nftTicker => { + return ( + typeof nftTicker === 'string' && + nftTicker.length > 0 && + nftTicker.length < 13 + ); +}; + +export const isValidChildNFTGroupId = nftGroupId => { + return nftGroupId != ''; +}; + +/* +export const isValidGroupNFTInitialQty = (nftInitialQty) => { + return ( + nftInitialQty >= 0 && + nftInitialQty < 1000000 + ); +}; +*/ + +export const isValidChildNFTDocumentUrl = nftDocumentUrl => { + return ( + typeof nftDocumentUrl === 'string' && + nftDocumentUrl.length >= 0 && + nftDocumentUrl.length < 68 + ); +}; + export const isValidTokenStats = tokenStats => { return ( typeof tokenStats === 'object' &&