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'}
+
>
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',