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.30.2", + "version": "2.30.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.30.2", + "version": "2.30.3", "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.30.2", + "version": "2.30.3", "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 @@ -13,6 +13,7 @@ getMintTargetOutputs, getMaxMintAmount, getNftParentGenesisTargetOutputs, + getNftParentMintTargetOutputs, } from 'slpv1'; import vectors from '../fixtures/vectors'; import { SEND_DESTINATION_ADDRESS } from '../fixtures/vectors'; @@ -412,4 +413,29 @@ }); }); }); + describe('Generate target outputs for an slpv1 nft parent mint tx', () => { + const { expectedReturns, expectedErrors } = + vectors.getNftParentMintTargetOutputs; + + // Successfully created targetOutputs + expectedReturns.forEach(expectedReturn => { + const { description, tokenId, mintQty, targetOutputs } = + expectedReturn; + it(`getNftParentMintTargetOutputs: ${description}`, () => { + expect(getNftParentMintTargetOutputs(tokenId, mintQty)).toEqual( + targetOutputs, + ); + }); + }); + + // Error cases + expectedErrors.forEach(expectedError => { + const { description, tokenId, mintQty, errorMsg } = expectedError; + it(`getNftParentMintTargetOutputs throws error for: ${description}`, () => { + expect(() => + getNftParentMintTargetOutputs(tokenId, mintQty), + ).toThrow(errorMsg); + }); + }); + }); }); 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 @@ -1368,4 +1368,58 @@ }, ], }, + getNftParentMintTargetOutputs: { + expectedReturns: [ + { + description: + 'Generates expected outputs for an NFT1 parent mint', + tokenId: MOCK_TOKEN_ID, + mintQty: '1000', + targetOutputs: [ + { + value: 0, + script: Buffer.from( + `6a04534c50000181044d494e5420${MOCK_TOKEN_ID}01020800000000000003e8`, + 'hex', + ), + }, + { value: 546 }, + { value: 546 }, + ], + }, + { + description: + 'Can create a target output for the largest mint qty supported by slpv1', + tokenId: MOCK_TOKEN_ID, + mintQty: '18446744073709551615', + targetOutputs: [ + { + value: 0, + script: Buffer.from( + `6a04534c50000181044d494e5420${MOCK_TOKEN_ID}010208ffffffffffffffff`, + 'hex', + ), + }, + { value: 546 }, + { value: 546 }, + ], + }, + ], + expectedErrors: [ + { + description: + 'Throws expected error if asked to mint 1 more than slpv1 max qty', + tokenId: MOCK_TOKEN_ID, + mintQty: '18446744073709551616', + error: 'bn outside of range', + }, + { + description: + 'Throws expected error if asked to mint a non-integer quantity', + tokenId: MOCK_TOKEN_ID, + mintQty: '100.123', + error: 'bn not an integer', + }, + ], + }, }; 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 @@ -16,6 +16,9 @@ // Cashtab follows the recommendation and will only mint 0-decimal NFT1 parent tokens const NFT1_PARENT_DECIMALS = 0; +// For SLPv1 Mint txs, Cashtab always puts the mint baton at mintBatonVout 2 +const CASHTAB_SLP1_MINT_MINTBATON_VOUT = 2; + /** * Get targetOutput for a SLP v1 genesis tx * @param {object} genesisConfig object containing token info for genesis tx @@ -380,12 +383,9 @@ // Convert to BN as this is what slp-mdm expects const mintQtyBigNumber = new BN(tokenSatoshis); - // Cashtab always puts the mint baton at mintBatonVout 2 - const CASHTAB_MINTBATON_VOUT = 2; - const script = TokenType1.mint( tokenId, - CASHTAB_MINTBATON_VOUT, + CASHTAB_SLP1_MINT_MINTBATON_VOUT, mintQtyBigNumber, ); @@ -486,3 +486,45 @@ return targetOutputs; }; + +/** + * Get targetOutput(s) for a SLPv1 NFT Parent MINT tx + * Note: Cashtab only supports slpv1 mints that preserve the baton at the wallet's address + * Note: Cashtab only supports NFT1 parents with decimals of 0 + * @param {string} tokenId + * @param {string} mintQty decimalized string for token qty. Must be an integer. + * @throws {error} if invalid input params are passed to TokenType1.mint + * @returns {array} targetOutput(s), e.g. [{value: 0, script: }, {value: 546}, {value: 546}] + * Note: we always return minted qty at index 1 + * Note we always return a mint baton at index 2 + */ +export const getNftParentMintTargetOutputs = (tokenId, mintQty) => { + // slp-mdm expects values in token satoshis, so we must undecimalize mintQty + + const script = NFT1.Group.mint( + tokenId, + CASHTAB_SLP1_MINT_MINTBATON_VOUT, + new BN(mintQty), + ); + + // Build targetOutputs per slpv1 spec + // Dust output at v1 receives the minted qty (per spec) + // Dust output at v2 for mint baton (per Cashtab) + + // Initialize with OP_RETURN at 0 index, per spec + // Note we do not include an address in outputs + // Cashtab behavior adds the wallet's change address if no output is added + const targetOutputs = [{ value: 0, script }]; + + // Add mint amount at index 1 + targetOutputs.push({ + value: appConfig.etokenSats, + }); + + // Add mint baton at index 2 + targetOutputs.push({ + value: appConfig.etokenSats, + }); + + return targetOutputs; +};