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.37.0",
+ "version": "3.38.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.37.1",
+ "version": "2.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cashtab",
- "version": "2.37.1",
+ "version": "2.38.0",
"dependencies": {
"@bitgo/utxo-lib": "^9.33.0",
"@zxing/browser": "^0.1.4",
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.37.1",
+ "version": "2.38.0",
"private": true,
"scripts": {
"start": "node scripts/start.js",
diff --git a/cashtab/src/chronik/fixtures/vectors.js b/cashtab/src/chronik/fixtures/vectors.js
--- a/cashtab/src/chronik/fixtures/vectors.js
+++ b/cashtab/src/chronik/fixtures/vectors.js
@@ -1071,6 +1071,178 @@
genesisSupply: '0.0000',
},
},
+ {
+ description: 'slpv1 NFT child',
+ tokenId:
+ '5d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326',
+ tokenInfo: {
+ tokenId:
+ '5d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326',
+ tokenType: {
+ protocol: 'SLP',
+ type: 'SLP_TOKEN_TYPE_NFT1_CHILD',
+ number: 65,
+ },
+ timeFirstSeen: 1713910791,
+ genesisInfo: {
+ tokenTicker: 'GC',
+ tokenName: 'Gordon Chen',
+ url: 'https://en.wikipedia.org/wiki/Tai-Pan_(novel)',
+ decimals: 0,
+ hash: '8247001da3bf5680011e26628228761b994a9e0a4ba3f1fdd826ddbf044e5d72',
+ },
+ block: {
+ height: 841509,
+ hash: '000000000000000003f0e8a3f0a4de0689311c5708d26b25851bb24a44027753',
+ timestamp: 1713913313,
+ },
+ },
+ genesisTx: {
+ txid: '5d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326',
+ version: 2,
+ inputs: [
+ {
+ prevOut: {
+ txid: 'faaba128601942a858abcce56d0da002c1f1d95e8c49ba4105c3d08aa76959d8',
+ outIdx: 3,
+ },
+ inputScript:
+ '483045022100e394332d19812c6b78ac39484dd755473348cc11920ceaea00c9185dc36cac9302203f04fbb661cd9137d5536667f03f89f2096b487a95b7a9eddbf2a33c7fb12d93412103771805b54969a9bea4e3eb14a82851c67592156ddb5e52d3d53677d14a40fba6',
+ value: 546,
+ sequenceNo: 4294967295,
+ token: {
+ tokenId:
+ '12a049d0da64652b4e8db68b6052ad0cda43cf0269190fe81040bed65ca926a3',
+ tokenType: {
+ protocol: 'SLP',
+ type: 'SLP_TOKEN_TYPE_NFT1_GROUP',
+ number: 129,
+ },
+ amount: '1',
+ isMintBaton: false,
+ entryIdx: 1,
+ },
+ outputScript:
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ },
+ {
+ prevOut: {
+ txid: '5478bbf6ebe4a0f0ac05994608b4b980264ba1225259f7f6c0f573e998be98e6',
+ outIdx: 2,
+ },
+ inputScript:
+ '47304402200dd2615f8545e57157d0cba016db42d4e25688a265155c7c332cf049eec4300202206cc96ee2f25141302f5e2aaade959ef9d972739f054585cf5dedb6bfec2f5928412103771805b54969a9bea4e3eb14a82851c67592156ddb5e52d3d53677d14a40fba6',
+ value: 32767046,
+ sequenceNo: 4294967295,
+ outputScript:
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ },
+ ],
+ outputs: [
+ {
+ value: 0,
+ outputScript:
+ '6a04534c500001410747454e455349530247430b476f72646f6e204368656e2d68747470733a2f2f656e2e77696b6970656469612e6f72672f77696b692f5461692d50616e5f286e6f76656c29208247001da3bf5680011e26628228761b994a9e0a4ba3f1fdd826ddbf044e5d7201004c00080000000000000001',
+ },
+ {
+ value: 546,
+ outputScript:
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ token: {
+ tokenId:
+ '5d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326',
+ tokenType: {
+ protocol: 'SLP',
+ type: 'SLP_TOKEN_TYPE_NFT1_CHILD',
+ number: 65,
+ },
+ amount: '1',
+ isMintBaton: false,
+ entryIdx: 0,
+ },
+ },
+ {
+ value: 32766028,
+ outputScript:
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ },
+ ],
+ lockTime: 0,
+ timeFirstSeen: 1713910791,
+ size: 505,
+ isCoinbase: false,
+ tokenEntries: [
+ {
+ tokenId:
+ '5d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326',
+ tokenType: {
+ protocol: 'SLP',
+ type: 'SLP_TOKEN_TYPE_NFT1_CHILD',
+ number: 65,
+ },
+ txType: 'GENESIS',
+ isInvalid: false,
+ burnSummary: '',
+ failedColorings: [],
+ actualBurnAmount: '0',
+ intentionalBurn: '0',
+ burnsMintBatons: false,
+ groupTokenId:
+ '12a049d0da64652b4e8db68b6052ad0cda43cf0269190fe81040bed65ca926a3',
+ },
+ {
+ tokenId:
+ '12a049d0da64652b4e8db68b6052ad0cda43cf0269190fe81040bed65ca926a3',
+ tokenType: {
+ protocol: 'SLP',
+ type: 'SLP_TOKEN_TYPE_NFT1_GROUP',
+ number: 129,
+ },
+ txType: 'NONE',
+ isInvalid: false,
+ burnSummary: '',
+ failedColorings: [],
+ actualBurnAmount: '0',
+ intentionalBurn: '0',
+ burnsMintBatons: false,
+ },
+ ],
+ tokenFailedParsings: [],
+ tokenStatus: 'TOKEN_STATUS_NORMAL',
+ block: {
+ height: 841509,
+ hash: '000000000000000003f0e8a3f0a4de0689311c5708d26b25851bb24a44027753',
+ timestamp: 1713913313,
+ },
+ },
+ returned: {
+ tokenType: {
+ protocol: 'SLP',
+ type: 'SLP_TOKEN_TYPE_NFT1_CHILD',
+ number: 65,
+ },
+ timeFirstSeen: 1713910791,
+ genesisInfo: {
+ tokenTicker: 'GC',
+ tokenName: 'Gordon Chen',
+ url: 'https://en.wikipedia.org/wiki/Tai-Pan_(novel)',
+ decimals: 0,
+ hash: '8247001da3bf5680011e26628228761b994a9e0a4ba3f1fdd826ddbf044e5d72',
+ },
+ groupTokenId:
+ '12a049d0da64652b4e8db68b6052ad0cda43cf0269190fe81040bed65ca926a3',
+ block: {
+ height: 841509,
+ hash: '000000000000000003f0e8a3f0a4de0689311c5708d26b25851bb24a44027753',
+ timestamp: 1713913313,
+ },
+ genesisMintBatons: 0,
+ genesisOutputScripts: [
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ ],
+ genesisSupply: '1',
+ },
+ },
],
expectedErrors: [
{
diff --git a/cashtab/src/chronik/index.js b/cashtab/src/chronik/index.js
--- a/cashtab/src/chronik/index.js
+++ b/cashtab/src/chronik/index.js
@@ -473,6 +473,18 @@
tokenCache.block = tokenInfo.block;
}
+ if (tokenType.type === 'SLP_TOKEN_TYPE_NFT1_CHILD') {
+ // If this is an SLP1 NFT
+ // Get the groupTokenId
+ // This is available from the .tx() call and will never change, so it should also be cached
+ for (const tokenEntry of genesisTxInfo.tokenEntries) {
+ const { txType } = tokenEntry;
+ if (txType === 'GENESIS') {
+ const { groupTokenId } = tokenEntry;
+ tokenCache.groupTokenId = groupTokenId;
+ }
+ }
+ }
// Note: if it is not confirmed, we can update the cache later when we try to use this value
return tokenCache;
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
@@ -2,7 +2,7 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useContext } from 'react';
import { Link, useParams } from 'react-router-dom';
import { WalletContext } from 'wallet/context';
import PrimaryButton, {
@@ -10,7 +10,7 @@
IconButton,
CopyIconButton,
} from 'components/Common/Buttons';
-import { TxLink, SwitchLabel, Info } from 'components/Common/Atoms';
+import { TxLink, SwitchLabel } from 'components/Common/Atoms';
import BalanceHeaderToken from 'components/Common/BalanceHeaderToken';
import { useNavigate } from 'react-router-dom';
import { Event } from 'components/Common/GoogleAnalytics';
@@ -39,6 +39,8 @@
getNftChildGenesisInput,
getNftParentFanInputs,
getNftParentFanTxTargetOutputs,
+ getNft,
+ getNftChildSendTargetOutputs,
} from 'slpv1';
import { sendXec } from 'transactions';
import { hasEnoughToken, decimalizeTokenAmount } from 'wallet';
@@ -76,14 +78,26 @@
NftRow,
NftCol,
NftTokenIdAndCopyIcon,
+ NftNameTitle,
+ NftCollectionTitle,
} from 'components/Etokens/Token/styled';
import CreateTokenForm from 'components/Etokens/CreateTokenForm';
-import { getAllTxHistoryByTokenId, getChildNftsFromParent } from 'chronik';
+import {
+ getAllTxHistoryByTokenId,
+ getChildNftsFromParent,
+ getTokenGenesisInfo,
+} from 'chronik';
const Token = () => {
let navigate = useNavigate();
- const { apiError, cashtabState, chronik, chaintipBlockheight, loading } =
- React.useContext(WalletContext);
+ const {
+ apiError,
+ cashtabState,
+ updateCashtabState,
+ chronik,
+ chaintipBlockheight,
+ loading,
+ } = useContext(WalletContext);
const { settings, wallets, cashtabCache } = cashtabState;
const wallet = wallets.length > 0 ? wallets[0] : false;
const walletState = getWalletState(wallet);
@@ -116,6 +130,7 @@
let isSupportedToken = false;
let isNftParent = false;
+ let isNftChild = false;
// Assign default values which will be presented for any token without explicit support
let renderedTokenType = `${tokenType.protocol} ${tokenType.number} ${tokenType.type}`;
@@ -143,6 +158,8 @@
renderedTokenType = 'NFT';
renderedTokenDescription =
'eCash NFT. NFT supply is always 1. This NFT may belong to an NFT collection.';
+ isSupportedToken = true;
+ isNftChild = true;
break;
}
default: {
@@ -249,6 +266,43 @@
}
};
+ const updateNftCachedInfo = async tokenId => {
+ const cachedInfoWithGroupTokenId = await getTokenGenesisInfo(
+ chronik,
+ tokenId,
+ );
+ cashtabCache.tokens.set(tokenId, cachedInfoWithGroupTokenId);
+ updateCashtabState('cashtabCache', cashtabCache);
+ };
+ const addNftCollectionToCache = async nftParentTokenId => {
+ const nftParentCachedInfo = await getTokenGenesisInfo(
+ chronik,
+ nftParentTokenId,
+ );
+ cashtabCache.tokens.set(nftParentTokenId, nftParentCachedInfo);
+ updateCashtabState('cashtabCache', cashtabCache);
+ };
+
+ useEffect(() => {
+ if (cachedInfo.tokenType.type === 'SLP_TOKEN_TYPE_NFT1_CHILD') {
+ // Check if we have its groupTokenId
+ if (typeof cachedInfo.groupTokenId === 'undefined') {
+ // If this is an NFT and its groupTokenId is not cached
+ // Update this tokens cached info
+ updateNftCachedInfo(tokenId);
+ } else {
+ // If we do have a groupTokenId, check if we have cached token info about the group
+ const nftCollectionCachedInfo = cashtabCache.tokens.get(
+ cachedInfo.groupTokenId,
+ );
+ if (typeof nftCollectionCachedInfo === 'undefined') {
+ // If we do not have the NFT collection token info in cache, add it
+ addNftCollectionToCache(cachedInfo.groupTokenId);
+ }
+ }
+ }
+ }, [tokenId, cachedInfo]);
+
useEffect(() => {
if (typeof cashtabCache.tokens.get(tokenId) === 'undefined') {
// Wait for token info to be available from cache
@@ -321,7 +375,7 @@
);
setAvailableNftInputs(availableNftMintInputs.length);
}
- }, [wallet.state.slpUtxos]);
+ }, [wallet.state.slpUtxos, isNftParent]);
useEffect(() => {
if (nftChildGenesisInput.length > 0) {
@@ -348,19 +402,6 @@
};
async function sendToken() {
- setFormData({
- ...formData,
- });
-
- if (
- !formData.address ||
- !formData.amount ||
- Number(formData.amount <= 0) ||
- sendTokenAmountError
- ) {
- return;
- }
-
// Track number of SLPA send transactions and
// SLPA token IDs
Event('SendToken.js', 'Send', tokenId);
@@ -378,18 +419,19 @@
try {
// Get input utxos for slpv1 send tx
- const tokenInputInfo = getSendTokenInputs(
- wallet.state.slpUtxos,
- tokenId,
- amount,
- decimals,
- );
+ const tokenInputInfo = !isNftChild
+ ? getSendTokenInputs(
+ wallet.state.slpUtxos,
+ tokenId,
+ amount,
+ decimals,
+ )
+ : undefined;
// Get targetOutputs for an slpv1 send tx
- const tokenSendTargetOutputs = getSlpSendTargetOutputs(
- tokenInputInfo,
- cleanAddress,
- );
+ const tokenSendTargetOutputs = isNftChild
+ ? getNftChildSendTargetOutputs(tokenId, cleanAddress)
+ : getSlpSendTargetOutputs(tokenInputInfo, cleanAddress);
// Build and broadcast the tx
const { response } = await sendXec(
@@ -410,7 +452,9 @@
? appConfig.minFee
: appConfig.defaultFee,
chaintipBlockheight,
- tokenInputInfo.tokenInputs,
+ isNftChild
+ ? getNft(tokenId, wallet.state.slpUtxos)
+ : tokenInputInfo.tokenInputs,
);
toast(
@@ -419,7 +463,7 @@
target="_blank"
rel="noopener noreferrer"
>
- eToken sent
+ {isNftChild ? 'NFT sent' : 'eToken sent'}
,
{
icon: ,
@@ -427,7 +471,7 @@
);
clearInputForms();
} catch (e) {
- console.error(`Error sending token`, e);
+ console.error(`Error sending ${isNftChild ? 'NFT' : 'token'}`, e);
toast.error(`${e}`);
}
}
@@ -921,14 +965,39 @@
/>
)}
-
+ {renderedTokenType === 'NFT' ? (
+ <>
+ {tokenName}
+ {typeof cachedInfo.groupTokenId !==
+ 'undefined' &&
+ typeof cashtabCache.tokens.get(
+ cachedInfo.groupTokenId,
+ ) !== 'undefined' && (
+
+ NFT from collection "
+
+ {
+ cashtabCache.tokens.get(
+ cachedInfo.groupTokenId,
+ ).genesisInfo.tokenName
+ }
+
+ "
+
+ )}
+ >
+ ) : (
+
+ )}
-
- {
- cachedNftInfo
- .genesisInfo
- .tokenName
- }
-
+ {typeof tokens.get(
+ nftTokenId,
+ ) !==
+ 'undefined' ? (
+
+ {
+ cachedNftInfo
+ .genesisInfo
+ .tokenName
+ }
+
+ ) : (
+ cachedNftInfo
+ .genesisInfo
+ .tokenName
+ )}
>
)}
@@ -1144,9 +1222,6 @@
>
)}
{apiError && }
- {renderedTokenType === 'NFT' && (
- ℹ️ NFT actions coming soon
- )}
{isSupportedToken && (
@@ -1215,30 +1290,39 @@
-
-
-
+ {!isNftChild && (
+
+
+
+ )}
checkForConfirmationBeforeSendEtoken()
@@ -1364,43 +1448,51 @@
)}
>
)}
-
-
- // We turn everything else off, whether we are turning this one on or off
- setSwitches({
- ...switchesOff,
- showAirdrop:
- !switches.showAirdrop,
- })
- }
- />
-
- Airdrop XEC to {tokenTicker} holders
-
-
- {switches.showAirdrop && (
-
-
-
- Airdrop Calculator
-
-
-
+ {!isNftChild && (
+ <>
+
+
+ // We turn everything else off, whether we are turning this one on or off
+ setSwitches({
+ ...switchesOff,
+ showAirdrop:
+ !switches.showAirdrop,
+ })
+ }
+ />
+
+ Airdrop XEC to {tokenTicker}{' '}
+ holders
+
+
+ {switches.showAirdrop && (
+
+
+
+ Airdrop Calculator
+
+
+
+ )}
+ >
)}
- {!isNftParent && (
+ {!isNftParent && !isNftChild && (
<>
props.theme.contrast};
+`;
+export const NftCollectionTitle = styled.div`
+ font-size: 18px;
+ color: ${props => props.theme.contrast};
+`;
diff --git a/cashtab/src/components/Etokens/__tests__/TokenActions.test.js b/cashtab/src/components/Etokens/__tests__/TokenActions.test.js
--- a/cashtab/src/components/Etokens/__tests__/TokenActions.test.js
+++ b/cashtab/src/components/Etokens/__tests__/TokenActions.test.js
@@ -25,6 +25,8 @@
slp1NftParentWithChildrenMocks,
slp1NftChildMocks,
} from 'components/Etokens/fixtures/mocks';
+import CashtabCache from 'config/CashtabCache';
+import { cashtabCacheToJSON } from 'helpers';
// https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, 'matchMedia', {
@@ -606,6 +608,14 @@
expect(screen.getByText('Gordon Chen')).toBeInTheDocument();
});
it('SLP1 NFT', async () => {
+ const hex =
+ '0200000002268322a2a8e67fe9efdaf15c9eb7397fb640ae32d8a245c2933f9eb967ff9b5d010000006a47304402205421a0ab0fa58e20fbe2632e58cbcee64e27f680c21675353b96a541f0576e39022019000e6aee98a49953c8581e2f04a140e983ea249d7884560ecc99b5ec6a87774121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffffef76d01776229a95c45696cf68f2f98c8332d0c53e3f24e73fd9c6deaf792618030000006a47304402207a1bce20f7f66ee2a4125c52a8f23b9a561269c0e87aad435ec33358e681233f02206d080cd78170257710afa02d29d61844c7450333db87e1b6a13268cc49228fde4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff030000000000000000376a04534c500001410453454e44205d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a222832608000000000000000122020000000000001976a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac68330f00000000001976a91400549451e5c22b18686cacdf34dce649e5ec3be288ac00000000';
+ const txid =
+ 'b2c53165d7a6b0d39a0d0939e4ffd47db2441941ecbaac9c053323deaef08a20';
+ mockedChronik.setMock('broadcastTx', {
+ input: hex,
+ output: { txid },
+ });
render(
{
+ // Use wallet with nft utxo as only utxo
+ // Preset a cache without groupTokenId
+ // Use existing tx and token mocks
+
+ // We need to use a unique mockedChronik for this test, with a minted NFT utxo but no parent utxo
+
+ // The user actions available for the child NFTs depend on whether or not the NFTs exist in the user's wallet
+ const renderChildNftsMockedChronik =
+ await initializeCashtabStateForTests(
+ {
+ ...tokenTestWallet,
+ state: {
+ ...tokenTestWallet.state,
+ slpUtxos: [
+ // Only a child NFT in the utxo set
+ slp1NftChildMocks.utxos[0],
+ ],
+ tokens: new Map([
+ [
+ '5d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326',
+ '1',
+ ],
+ ]),
+ },
+ },
+ localforage,
+ );
+ const mockCashtabCacheWithNft = new CashtabCache([
+ [
+ slp1NftChildMocks.tokenId,
+ {
+ // note that this mock DOES NOT include groupTokenId
+ ...slp1NftChildMocks.token,
+ genesisSupply: '1',
+ genesisOutputScripts: [
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ ],
+ genesisMintBatons: 0,
+ },
+ ],
+ ]);
+
+ await localforage.setItem(
+ 'cashtabCache',
+ cashtabCacheToJSON(mockCashtabCacheWithNft),
+ );
+
+ // Build chronik mocks that Cashtab would use to add token info to cache
+ for (const tokenMock of supportedTokens) {
+ renderChildNftsMockedChronik.setMock('token', {
+ input: tokenMock.tokenId,
+ output: tokenMock.token,
+ });
+ renderChildNftsMockedChronik.setMock('tx', {
+ input: tokenMock.tokenId,
+ output: tokenMock.tx,
+ });
+ renderChildNftsMockedChronik.setTokenId(tokenMock.tokenId);
+ renderChildNftsMockedChronik.setUtxosByTokenId(tokenMock.tokenId, {
+ tokenId: tokenMock.tokenId,
+ utxos: tokenMock.utxos,
+ });
+ // Set tx history of parent tokenId to empty
+ renderChildNftsMockedChronik.setTxHistoryByTokenId(
+ tokenMock.tokenId,
+ [],
+ );
+ }
+
+ // Set tx history of parent tokenId to include an NFT
+ renderChildNftsMockedChronik.setTxHistoryByTokenId(
+ slp1NftParentWithChildrenMocks.tokenId,
+ [slp1NftChildMocks.tx],
+ );
+
+ render(
+ ,
+ );
+
+ const { tokenName } = slp1NftChildMocks.token.genesisInfo;
- // We see an info notice that actions will be coming soon
+ // Wait for element to get token info and load
expect(
- screen.getByText('ℹ️ NFT actions coming soon'),
+ (await screen.findAllByText(new RegExp(tokenName)))[0],
).toBeInTheDocument();
+
+ // NFT image is rendered
+ expect(
+ screen.getByAltText(`icon for ${slp1NftChildMocks.tokenId}`),
+ ).toBeInTheDocument();
+
+ // We can click an info icon to learn more about this token type
+ await userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Click for more info about this token type',
+ }),
+ );
+
+ expect(
+ screen.getByText(
+ `eCash NFT. NFT supply is always 1. This NFT may belong to an NFT collection.`,
+ ),
+ ).toBeInTheDocument();
+
+ // Close out of the info modal
+ await userEvent.click(screen.getByText('OK'));
+
+ // The NFT Token name is the title
+ expect(screen.getByText('Gordon Chen')).toBeInTheDocument();
+
+ // We see what collection this NFT is from
+ expect(screen.getByText(/NFT from collection/)).toBeInTheDocument();
+ expect(
+ screen.getByText('The Four Half-Coins of Jin-qua'),
+ ).toBeInTheDocument();
+
+ // Token actions are available for NFTs
+ expect(screen.getByTitle('Token Actions')).toBeInTheDocument();
+
+ // We see an address input field
+ const addrInput = screen.getByPlaceholderText('Address');
+ expect(addrInput).toBeInTheDocument();
});
it('ALP token', async () => {
render(
diff --git a/cashtab/src/slpv1/__tests__/index.test.js b/cashtab/src/slpv1/__tests__/index.test.js
--- a/cashtab/src/slpv1/__tests__/index.test.js
+++ b/cashtab/src/slpv1/__tests__/index.test.js
@@ -513,9 +513,12 @@
describe('Get targetOutputs for an NFT1 child send tx', () => {
const { expectedReturns } = vectors.getNftChildSendTargetOutputs;
expectedReturns.forEach(expectedReturn => {
- const { description, tokenId, returned } = expectedReturn;
+ const { description, tokenId, destinationAddress, returned } =
+ expectedReturn;
it(`getNftChildSendTargetOutputs: ${description}`, () => {
- expect(getNftChildSendTargetOutputs(tokenId)).toEqual(returned);
+ expect(
+ getNftChildSendTargetOutputs(tokenId, destinationAddress),
+ ).toEqual(returned);
});
});
});
diff --git a/cashtab/src/slpv1/fixtures/vectors.js b/cashtab/src/slpv1/fixtures/vectors.js
--- a/cashtab/src/slpv1/fixtures/vectors.js
+++ b/cashtab/src/slpv1/fixtures/vectors.js
@@ -1854,6 +1854,7 @@
{
description: 'We can get the target outputs for sending an NFT',
tokenId: MOCK_TOKEN_ID,
+ destinationAddress: SEND_DESTINATION_ADDRESS,
returned: [
{
value: 0,
@@ -1863,6 +1864,7 @@
),
},
{
+ address: SEND_DESTINATION_ADDRESS,
value: appConfig.dustSats,
},
],
diff --git a/cashtab/src/slpv1/index.js b/cashtab/src/slpv1/index.js
--- a/cashtab/src/slpv1/index.js
+++ b/cashtab/src/slpv1/index.js
@@ -719,7 +719,7 @@
* So, the user interface for input selection is what mostly drives this tx
* @param {string} tokenId tokenId of the Parent (aka Group)
*/
-export const getNftChildSendTargetOutputs = tokenId => {
+export const getNftChildSendTargetOutputs = (tokenId, destinationAddress) => {
// slp-mdm accepts an array of BN for send amounts
const SEND_ONE_CHILD = [new BN(1)];
const script = NFT1.Child.send(tokenId, SEND_ONE_CHILD);
@@ -728,5 +728,8 @@
// - Cashtab only supports sending one NFT at a time
// - All NFT Child inputs will have amount of 1
// Therefore, we will have no change, and every send tx will have only one token utxo output
- return [{ value: 0, script }, { value: 546 }];
+ return [
+ { value: 0, script },
+ { address: destinationAddress, value: 546 },
+ ];
};