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.32.8", + "version": "2.32.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.32.8", + "version": "2.32.9", "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.32.8", + "version": "2.32.9", "private": true, "scripts": { "start": "node scripts/start.js", 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 @@ -16,6 +16,10 @@ getNftParentMintTargetOutputs, getNftParentFanInputs, getNftParentFanTxTargetOutputs, + getNftChildGenesisInput, + getNftChildGenesisTargetOutputs, + getNft, + getNftChildSendTargetOutputs, } from 'slpv1'; import vectors from '../fixtures/vectors'; import { SEND_DESTINATION_ADDRESS } from '../fixtures/vectors'; @@ -474,4 +478,45 @@ }); }); }); + describe('Gets required input for an NFT1 child genesis tx, if present in given slpUtxos', () => { + const { expectedReturns } = vectors.getNftChildGenesisInput; + expectedReturns.forEach(vector => { + const { description, tokenId, slpUtxos, returned } = vector; + it(`getNftChildGenesisInput: ${description}`, () => { + expect( + getNftChildGenesisInput(tokenId, slpUtxos), + ).toStrictEqual(returned); + }); + }); + }); + describe('Get targetOutputs for an NFT1 child genesis tx', () => { + const { expectedReturns } = vectors.getNftChildGenesisTargetOutputs; + expectedReturns.forEach(expectedReturn => { + const { description, childGenesisConfig, returned } = + expectedReturn; + it(`getNftChildGenesisTargetOutputs: ${description}`, () => { + expect( + getNftChildGenesisTargetOutputs(childGenesisConfig), + ).toEqual(returned); + }); + }); + }); + describe('Gets NFT utxo for an NFT 1 child', () => { + const { expectedReturns } = vectors.getNft; + expectedReturns.forEach(vector => { + const { description, tokenId, slpUtxos, returned } = vector; + it(`getNft: ${description}`, () => { + expect(getNft(tokenId, slpUtxos)).toStrictEqual(returned); + }); + }); + }); + describe('Get targetOutputs for an NFT1 child send tx', () => { + const { expectedReturns } = vectors.getNftChildSendTargetOutputs; + expectedReturns.forEach(expectedReturn => { + const { description, tokenId, returned } = expectedReturn; + it(`getNftChildSendTargetOutputs: ${description}`, () => { + expect(getNftChildSendTargetOutputs(tokenId)).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 @@ -6,7 +6,10 @@ import appConfig from 'config/app'; import { mockBurnOpReturnTokenUtxos, mockBurnAllTokenUtxos } from './mocks'; import { BN } from 'slp-mdm'; -import { MAX_MINT_AMOUNT_TOKEN_SATOSHIS } from 'slpv1'; +import { + MAX_MINT_AMOUNT_TOKEN_SATOSHIS, + SLP1_NFT_CHILD_GENESIS_AMOUNT, +} from 'slpv1'; const GENESIS_MINT_ADDRESS = 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y'; export const SEND_DESTINATION_ADDRESS = @@ -1644,4 +1647,226 @@ }, ], }, + getNftChildGenesisInput: { + expectedReturns: [ + { + description: + 'Returns a single utxo of amount 1 if it exists in given utxo set', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: false, + }, + }, + ], + returned: [ + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: false, + }, + }, + ], + }, + { + description: + 'Does not return a single utxo of amount 1 if it exists in given utxo set and is a mint baton (not expected to ever happen)', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: true, + }, + }, + ], + returned: [], + }, + { + description: + 'Returns a single utxo of amount 1 even if more than 1 eligible utxos exist in given utxo set', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: false, + }, + }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: false, + }, + }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: false, + }, + }, + ], + returned: [ + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: SLP1_NFT_CHILD_GENESIS_AMOUNT, + isMintBaton: false, + }, + }, + ], + }, + { + description: + 'Returns an empty array even if parent token utxos exist but do not have amount === 1', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '2', + isMintBaton: false, + }, + }, + ], + returned: [], + }, + { + description: + 'Returns an empty array if no utxos of correct tokenId and amount exist', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: + '2222222222222222222222222222222222222222222222222222222222222222', + amount: '1', + isMintBaton: false, + }, + }, + ], + returned: [], + }, + ], + }, + getNftChildGenesisTargetOutputs: { + expectedReturns: [ + { + description: + 'We can generate the correct targetOutput for minting an NFT child genesis tx with data in all available fields', + childGenesisConfig: { + ticker: 'TEST', + name: 'My favorite NFT', + documentUrl: 'cashtab.com', + documentHash: + '3333333333333333333333333333333333333333333333333333333333333333', + }, + returned: [ + { + value: 0, + script: Buffer.from( + '6a04534c500001410747454e4553495304544553540f4d79206661766f72697465204e46540b636173687461622e636f6d20333333333333333333333333333333333333333333333333333333333333333301004c00080000000000000001', + 'hex', + ), + }, + { + value: appConfig.dustSats, + }, + ], + }, + { + description: + 'We can generate the correct targetOutput for minting an NFT child genesis tx with no data in any available fields', + childGenesisConfig: { + ticker: '', + name: '', + documentUrl: '', + documentHash: '', + }, + returned: [ + { + value: 0, + script: Buffer.from( + '6a04534c500001410747454e455349534c004c004c004c0001004c00080000000000000001', + 'hex', + ), + }, + { + value: appConfig.dustSats, + }, + ], + }, + ], + }, + getNft: { + expectedReturns: [ + { + description: 'Returns the NFT if it exists in given utxo set', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + }, + }, + ], + returned: [ + { + token: { + tokenId: MOCK_TOKEN_ID, + }, + }, + ], + }, + { + description: + 'Returns an empty array if no utxos of correct tokenId are in this utxo set', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: + '2222222222222222222222222222222222222222222222222222222222222222', + }, + }, + ], + returned: [], + }, + ], + }, + getNftChildSendTargetOutputs: { + expectedReturns: [ + { + description: 'We can get the target outputs for sending an NFT', + tokenId: MOCK_TOKEN_ID, + returned: [ + { + value: 0, + script: Buffer.from( + `6a04534c500001410453454e4420${MOCK_TOKEN_ID}080000000000000001`, + 'hex', + ), + }, + { + 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 @@ -28,7 +28,7 @@ // To mint NFTs in a Collection (i.e. NFT Child from NFT Parent), you must spend this qty of NFT Parent // This is a spec value -const SLP1_NFT_CHILD_GENESIS_AMOUNT = '1'; +export const SLP1_NFT_CHILD_GENESIS_AMOUNT = '1'; /** * Get targetOutput for a SLP v1 genesis tx @@ -633,3 +633,96 @@ return targetOutputs; }; + +/** + * We need to get a parent utxo with qty of exactly 1 + * This is burned to mint a child nft + * Ref https://github.com/simpleledger/slp-specifications/blob/master/slp-nft-1.md + * If we cannot find any utxos with qty of exactly 1, will need to create some with a fan-out tx + * This is handled by a separate function + * @param {string} tokenId tokenId of the parent aka Group + * @param {CashtabUtxo[]} slpUtxos What Cashtab stores at the wallet.state.slpUtxos key + * @returns {[CashtabUtxo] | []} Array of ONLY ONE cashtab utxo where tokenId === tokenId and token.amount === 1, if it exists + * Otherwise an empty array + */ +export const getNftChildGenesisInput = (tokenId, slpUtxos) => { + // Note that we do not use .filter() as we do in most "getInput" functions for SLP, + // because in this case we only want exactly 1 utxo + for (const utxo of slpUtxos) { + if ( + utxo.token?.tokenId === tokenId && + utxo.token?.isMintBaton === false && + utxo.token?.amount === SLP1_NFT_CHILD_GENESIS_AMOUNT + ) { + return [utxo]; + } + } + // We have not found a utxo that meets our conditions + // Return empty array + return []; +}; + +/** + * Get target outputs for minting an NFT + * Note that we get these inputs separately, from getNftChildGenesisInput and, if that fails, + * from making a fan-out tx + * Note we do not need the group tokenId, as this is implied in the tx by the input + * @param {object} childGenesisInfo + */ +export const getNftChildGenesisTargetOutputs = childGenesisConfig => { + const { ticker, name, documentUrl, documentHash } = childGenesisConfig; + const script = NFT1.Child.genesis(ticker, name, documentUrl, documentHash); + // We always mint exactly 1 NFT per child genesis tx, so no change is expected + // Will always have exactly 1 dust utxo at index 1 to hold this NFT + return [{ value: 0, script }, { value: appConfig.dustSats }]; +}; + +/** + * We are effectively getting this NFT + * The NFT is stored at a dust utxo from a previous NFT Child send tx or its NFT Child genesis tx + * Because this is an NFT, "there can be only one" of these utxos. The wallet either has it or it does not. + * @param {string} tokenId tokenId of the NFT (SLP1 NFT Child) + * @param {CashtabUtxo[]} slpUtxos What Cashtab stores at the wallet.state.slpUtxos key + * @returns {[CashtabUtxo] | []} Array of ONLY ONE cashtab utxo where tokenId === tokenId + * Otherwise an empty array + * + * Function could be called "getNftChildSendInput" -- however, we will probably use this function + * for more than simply getting the required input for sending an NFT + * + * NOTE + * We do not "check" to see if we have more than one utxo of this NFT + * This is not expected to happen -- though it could happen if this function is used in the wrong context, + * for example called with a tokenId of a token that is not an NFT1 child + * Dev responsibly -- imo it is not worth performing this check every time the function is called + * Only use this function when sending a type1 NFT child + */ +export const getNft = (tokenId, slpUtxos) => { + // Note that we do not use .filter() as we do in most "getInput" functions for SLP, + // because in this case we only want exactly 1 utxo + for (const utxo of slpUtxos) { + if (utxo.token?.tokenId === tokenId) { + return [utxo]; + } + } + // We have not found a utxo that meets our conditions + // Return empty array + return []; +}; + +/** + * Cashtab only supports sending one NFT1 child at a time + * Which child is sent is determined by input selection + * 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 => { + // 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); + + // Implementation notes + // - 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 }]; +};