diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "3.34.0", + "version": "3.35.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "2.34.4", + "version": "2.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.34.4", + "version": "2.35.0", "dependencies": { "@bitgo/utxo-lib": "^9.33.0", "@zxing/browser": "^0.1.4", @@ -17,6 +17,7 @@ "ecash-coinselect": "^2.2.0", "ecash-script": "^2.1.2", "ecashaddrjs": "^1.5.4", + "js-sha256": "^0.11.0", "localforage": "^1.9.0", "qrcode.react": "^3.1.0", "react": "^18.2.0", @@ -12749,6 +12750,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-sha256": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==" + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "2.34.4", + "version": "2.35.0", "private": true, "scripts": { "start": "node scripts/start.js", @@ -34,6 +34,7 @@ "ecash-coinselect": "^2.2.0", "ecash-script": "^2.1.2", "ecashaddrjs": "^1.5.4", + "js-sha256": "^0.11.0", "localforage": "^1.9.0", "qrcode.react": "^3.1.0", "react": "^18.2.0", diff --git a/cashtab/src/components/App/App.js b/cashtab/src/components/App/App.js --- a/cashtab/src/components/App/App.js +++ b/cashtab/src/components/App/App.js @@ -324,6 +324,11 @@ element={} /> + } + /> + } diff --git a/cashtab/src/components/Common/Inputs.js b/cashtab/src/components/Common/Inputs.js --- a/cashtab/src/components/Common/Inputs.js +++ b/cashtab/src/components/Common/Inputs.js @@ -151,6 +151,7 @@ placeholder = '', name = '', value = '', + disabled = false, handleInput, error = false, }) => { @@ -161,6 +162,7 @@ name={name} value={value} placeholder={placeholder} + disabled={disabled} invalid={typeof error === 'string'} onChange={e => handleInput(e)} /> @@ -174,6 +176,7 @@ placeholder: PropTypes.string, name: PropTypes.string, value: PropTypes.string, + disabled: PropTypes.bool, error: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), handleInput: PropTypes.func, }; diff --git a/cashtab/src/components/Etokens/CreateTokenForm/index.js b/cashtab/src/components/Etokens/CreateTokenForm/index.js --- a/cashtab/src/components/Etokens/CreateTokenForm/index.js +++ b/cashtab/src/components/Etokens/CreateTokenForm/index.js @@ -28,7 +28,11 @@ import getResizedImage from 'components/Etokens/icons/resizeImage'; import { token as tokenConfig } from 'config/token'; import appConfig from 'config/app'; -import { getSlpGenesisTargetOutput, getMaxMintAmount } from 'slpv1'; +import { + getSlpGenesisTargetOutput, + getMaxMintAmount, + getNftParentGenesisTargetOutputs, +} from 'slpv1'; import { sendXec } from 'transactions'; import { TokenNotificationIcon } from 'components/Common/CustomIcons'; import { explorer } from 'config/explorer'; @@ -36,7 +40,7 @@ import { hasEnoughToken } from 'wallet'; import { toast } from 'react-toastify'; import Switch from 'components/Common/Switch'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Form, SwitchRow, @@ -53,9 +57,11 @@ SummaryRow, TokenParam, } from 'components/Etokens/CreateTokenForm/styles'; +import { sha256 } from 'js-sha256'; const CreateTokenForm = () => { const navigate = useNavigate(); + const location = useLocation(); const { chronik, chaintipBlockheight, cashtabState } = React.useContext(WalletContext); const { settings, wallets } = cashtabState; @@ -80,6 +86,9 @@ const [zoom, setZoom] = useState(1); const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + // NFT handling + const [createNftCollection, setCreateNftCollection] = useState(false); + // Modal settings const [showConfirmCreateToken, setShowConfirmCreateToken] = useState(false); @@ -90,6 +99,7 @@ decimals: '', genesisQty: '', url: '', + hash: '', createWithMintBatonAtIndexTwo: false, }; const initialFormDataErrors = { @@ -98,6 +108,7 @@ decimals: false, genesisQty: false, url: false, + // No error for hash as this is only generated }; const [formData, setFormData] = useState(emptyFormData); const [formDataErrors, setFormDataErrors] = useState(initialFormDataErrors); @@ -108,6 +119,13 @@ // Note: We do not include a UI input for token document hash // Questionable value to casual users and requires significant complication + useEffect(() => { + // If we routed here from the Create NFT Collection link, toggle NFT switch to true + if (location?.pathname?.includes('create-nft-collection')) { + setCreateNftCollection(true); + } + }, []); + useEffect(() => { // After the user has created a token, we wait until the wallet has updated its balance // and the page is available, then we navigate to the page @@ -119,6 +137,16 @@ } }, [createdTokenId, tokens]); + useEffect(() => { + if (createNftCollection === true) { + // Cashtab only creates NFT1 Parent tokens (aka NFT Collections) with 0 decimals + setFormData(previous => ({ + ...previous, + decimals: '0', + })); + } + }, [createNftCollection]); + const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => { setCroppedAreaPixels(croppedAreaPixels); }, []); @@ -165,6 +193,24 @@ new Promise((resolve, reject) => { setLoading(true); try { + // Get the sha256 hash of the user's original uploaded file + // For an NFT, this will be set as the document hash + // Note that this will not match the hash of the image on the token server due to resizing + // and renaming + // The hash should be of the creator's original file + const hashreader = new FileReader(); + hashreader.readAsArrayBuffer(imgFile); + hashreader.addEventListener('load', () => { + // Handle Input expects an event with key target + // target to have keys name and hash + handleInput({ + target: { + name: 'hash', + value: sha256(hashreader.result), + }, + }); + }); + const reader = new FileReader(); const width = 512; @@ -345,6 +391,11 @@ })); break; } + case 'hash': { + // Do nothing, we disable user input for this field + // Input can only come from a user uploaded image + break; + } default: break; } @@ -448,14 +499,17 @@ ? tokenConfig.newTokenDefaultUrl : formData.url, genesisQty: formData.genesisQty, - documentHash: '', + // Support documentHash for NFT Collection, but only for uploaded image file + documentHash: createNftCollection ? formData.hash : '', mintBatonVout: createWithMintBatonAtIndexTwo ? 2 : null, }; - // create token with data in state fields + // Create type 1 slp token per specified user data try { // Get target outputs for an SLP v1 genesis tx - const targetOutputs = getSlpGenesisTargetOutput(configObj); + const targetOutputs = createNftCollection + ? getNftParentGenesisTargetOutputs(configObj) + : getSlpGenesisTargetOutput(configObj); const { response } = await sendXec( chronik, wallet, @@ -480,7 +534,7 @@ target="_blank" rel="noopener noreferrer" > - Token created! + {createNftCollection ? 'NFT Collection' : 'Token'} created! , { icon: TokenNotificationIcon, @@ -501,7 +555,9 @@ <> {showConfirmCreateToken && ( setShowConfirmCreateToken(false)} showCancelButton @@ -532,21 +588,33 @@ : formData.url} + {createNftCollection && ( + + Hash: + {formData.hash} + + )} )} - Create a Token + + Create {createNftCollection ? 'NFT Collection' : 'Token'} +
+ {createNftCollection && ( + + )} - Token supply + + {createNftCollection + ? 'NFT Collection Size' + : 'Token supply'} + - Create eToken + Create {createNftCollection ? 'NFT Collection' : 'eToken'} diff --git a/cashtab/src/components/Etokens/Etokens.js b/cashtab/src/components/Etokens/Etokens.js --- a/cashtab/src/components/Etokens/Etokens.js +++ b/cashtab/src/components/Etokens/Etokens.js @@ -10,7 +10,7 @@ import { getWalletState } from 'utils/cashMethods'; import appConfig from 'config/app'; import { getUserLocale } from 'helpers'; -import { PrimaryLink } from 'components/Common/Buttons'; +import { PrimaryLink, SecondaryLink } from 'components/Common/Buttons'; import { Input } from 'components/Common/Inputs'; const EtokensCtn = styled.div` @@ -107,14 +107,18 @@ ) : ( - + Create eToken + + + Create NFT Collection + + {Array.isArray(tokensInWallet) && tokensInWallet.length > 0 ? ( <> diff --git a/cashtab/src/components/Etokens/Token/index.js b/cashtab/src/components/Etokens/Token/index.js --- a/cashtab/src/components/Etokens/Token/index.js +++ b/cashtab/src/components/Etokens/Token/index.js @@ -10,7 +10,7 @@ IconButton, CopyIconButton, } from 'components/Common/Buttons'; -import { TxLink, SwitchLabel } from 'components/Common/Atoms'; +import { TxLink, SwitchLabel, Info } from 'components/Common/Atoms'; import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { useNavigate } from 'react-router-dom'; import { Event } from 'components/Common/GoogleAnalytics'; @@ -816,6 +816,12 @@ {apiError && } + {renderedTokenType === 'SLP NFT Collection' && ( + + ℹ️ Cashtab support for minting NFTs is coming + soon + + )} {isSupportedToken && ( diff --git a/cashtab/src/components/Etokens/__tests__/CreateToken.test.js b/cashtab/src/components/Etokens/__tests__/CreateToken.test.js --- a/cashtab/src/components/Etokens/__tests__/CreateToken.test.js +++ b/cashtab/src/components/Etokens/__tests__/CreateToken.test.js @@ -88,7 +88,7 @@ ); // Renders CreateTokenForm, as this wallet has sufficient balance to create a token - expect(await screen.findByText('Create a Token')).toBeInTheDocument(); + expect(await screen.findByText('Create Token')).toBeInTheDocument(); // Does not render insufficient balance alert expect( @@ -127,7 +127,7 @@ expect(await screen.findByText('0.00 XEC')).toBeInTheDocument(); // We do not see the Create a Token form - expect(screen.queryByText('Create a Token')).not.toBeInTheDocument(); + expect(screen.queryByText('Create Token')).not.toBeInTheDocument(); // Renders expected alert // Note: the component is expected to load before fiatPrice loads diff --git a/cashtab/src/components/Etokens/__tests__/CreateTokenForm.test.js b/cashtab/src/components/Etokens/__tests__/CreateTokenForm.test.js --- a/cashtab/src/components/Etokens/__tests__/CreateTokenForm.test.js +++ b/cashtab/src/components/Etokens/__tests__/CreateTokenForm.test.js @@ -8,7 +8,7 @@ MOCK_CHRONIK_TOKEN_CALL, MOCK_CHRONIK_GENESIS_TX_CALL, } from 'components/App/fixtures/mocks'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { explorer } from 'config/explorer'; @@ -98,6 +98,13 @@ />, ); + // Wait for Cashtab to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + // The user enters valid token metadata await user.type( await screen.findByPlaceholderText('Enter a name for your token'), @@ -114,9 +121,7 @@ '2', ); await user.type( - await screen.findByPlaceholderText( - 'Enter the supply of your token', - ), + await screen.findByPlaceholderText('Enter initial token supply'), '600000', ); await user.type( @@ -192,6 +197,13 @@ />, ); + // Wait for Cashtab to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + // The user enters valid token metadata await user.type( await screen.findByPlaceholderText('Enter a name for your token'), @@ -208,9 +220,7 @@ '2', ); await user.type( - await screen.findByPlaceholderText( - 'Enter the supply of your token', - ), + await screen.findByPlaceholderText('Enter initial token supply'), '600000', ); await user.type( @@ -238,4 +248,80 @@ // We are sent to its token-action page expect(await screen.findByTitle('Token Stats')).toBeInTheDocument(); }); + it('User can create an NFT collection', async () => { + const mockedChronik = await initializeCashtabStateForTests( + walletWithXecAndTokens, + localforage, + ); + // Add tx mock to mockedChronik + const hex = + '0200000001fe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006a473044022064d8618b1b6131f6d1b611d67107d0962f7c1d951a5cadcccf3f502952a1723002202f9fd50a185b683475fb9c0a394fef4b6aaaf188cdb3747a1c38d4366571a3614121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff0300000000000000006e6a04534c500001810747454e45534953033448432454686520466f75722048616c662d436f696e73206f66204a696e2d71756120283448432925656e2e77696b6970656469612e6f72672f77696b692f5461692d50616e5f286e6f76656c294c0001004c0008000000000000000422020000000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac387f0e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; + const txid = + 'ed6e5838af475cf2d35e537abb06cb497bb9e69ba071ba06a678d35764a39c9a'; + mockedChronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); + + // Load component with create-nft-collection route + render( + , + ); + + // Wait for Cashtab to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // The user enters valid token metadata + await user.type( + await screen.findByPlaceholderText( + 'Enter a name for your NFT collection', + ), + 'The Four Half-Coins of Jin-qua (4HC)', + ); + await user.type( + await screen.findByPlaceholderText( + 'Enter a ticker for your NFT collection', + ), + '4HC', + ); + + // The decimals input is disabled + const decimalsInput = screen.getByPlaceholderText( + 'Enter number of decimal places', + ); + expect(decimalsInput).toHaveProperty('disabled', true); + + // Decimals is set to 0 + expect(decimalsInput).toHaveValue('0'); + await user.type( + await screen.findByPlaceholderText('Enter NFT collection size'), + '4', + ); + await user.type( + await screen.findByPlaceholderText( + 'Enter a website for your NFT collection', + ), + 'en.wikipedia.org/wiki/Tai-Pan_(novel)', + ); + + // Click the Create eToken button + await user.click( + screen.getByRole('button', { name: /Create NFT Collection/ }), + ); + + // Click OK on confirmation modal + await user.click(screen.getByText('OK')); + + // Verify notification triggered + expect( + await screen.findByText('NFT Collection created!'), + ).toHaveAttribute('href', `${explorer.blockExplorerUrl}/tx/${txid}`); + }); }); diff --git a/cashtab/src/components/Etokens/__tests__/Token.test.js b/cashtab/src/components/Etokens/__tests__/Token.test.js --- a/cashtab/src/components/Etokens/__tests__/Token.test.js +++ b/cashtab/src/components/Etokens/__tests__/Token.test.js @@ -123,7 +123,7 @@ await clearLocalForage(localforage); }); - it('Renders the SendToken screen with send address input', async () => { + it('Renders the Token screen with send address input', async () => { render( { expect(getTokenDocumentUrlError('mywebsite')).toBe('Invalid URL'); }); + it(`Accepts a common wikipedia URL convention (underscore and parenthesis)`, () => { + expect( + getTokenDocumentUrlError( + 'https://en.wikipedia.org/wiki/Tai-Pan_(novel)', + ), + ).toBe(false); + }); it(`Expected error for a domain input as numbers ${appConfig.tokenTicker} token document URL`, () => { expect(getTokenDocumentUrlError(12345)).toBe('Invalid URL'); }); diff --git a/cashtab/src/validation/index.js b/cashtab/src/validation/index.js --- a/cashtab/src/validation/index.js +++ b/cashtab/src/validation/index.js @@ -224,7 +224,7 @@ '^(https?:\\/\\/)?' + // protocol (optional) '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address - '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path + '(\\:\\d+)?(\\/[-a-z\\d%_().~+]*)*' + // port and path '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string '(\\#[-a-z\\d_]*)?$', 'i',