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 }, + ]; };