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 @@ -14,6 +14,8 @@ getMaxMintAmount, getNftParentGenesisTargetOutputs, getNftParentMintTargetOutputs, + getNftParentFanInputs, + getNftParentFanTxTargetOutputs, } from 'slpv1'; import vectors from '../fixtures/vectors'; import { SEND_DESTINATION_ADDRESS } from '../fixtures/vectors'; @@ -438,4 +440,38 @@ }); }); }); + describe('Gets required inputs for an NFT1 parent fan-out tx, if present in given slpUtxos', () => { + const { expectedReturns } = vectors.getNftParentFanInputs; + expectedReturns.forEach(vector => { + const { description, tokenId, slpUtxos, returned } = vector; + it(`getNftParentFanInputs: ${description}`, () => { + expect(getNftParentFanInputs(tokenId, slpUtxos)).toStrictEqual( + returned, + ); + }); + }); + }); + describe('Generate target outputs for an NFT1 parent fan-out tx', () => { + const { expectedReturns, expectedErrors } = + vectors.getNftParentFanTxTargetOutputs; + + // Successfully created targetOutputs + expectedReturns.forEach(expectedReturn => { + const { description, fanInputs, returned } = expectedReturn; + it(`getNftParentFanTxTargetOutputs: ${description}`, () => { + expect(getNftParentFanTxTargetOutputs(fanInputs)).toEqual( + returned, + ); + }); + }); + + expectedErrors.forEach(expectedError => { + const { description, fanInputs, error } = expectedError; + it(`getNftParentFanTxTargetOutputs throws error for: ${description}`, () => { + expect(() => getNftParentFanTxTargetOutputs(fanInputs)).toThrow( + error, + ); + }); + }); + }); }); 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 @@ -1422,4 +1422,226 @@ }, ], }, + getNftParentFanInputs: { + expectedReturns: [ + { + description: 'Gets NFT1 parent spendable utxo with qty !== 1', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '100', + isMintBaton: false, + }, + }, + ], + returned: [ + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '100', + isMintBaton: false, + }, + }, + ], + }, + { + description: + 'Ignores NFT1 parent spendable utxo with qty === 1', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1', + isMintBaton: false, + }, + }, + ], + returned: [], + }, + { + description: + 'Returns multiple utxos at multiple amounts and ignores amount === 1', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1', + isMintBaton: false, + }, + }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '2', + isMintBaton: false, + }, + }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '3', + isMintBaton: false, + }, + }, + ], + returned: [ + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '2', + isMintBaton: false, + }, + }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '3', + isMintBaton: false, + }, + }, + ], + }, + { + description: + 'Ignores a utxo if it has the right tokenId, the right amount, but is a mint baton for some reason (not expected to ever happen)', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + [ + { value: 546 }, + { + token: { + tokenId: MOCK_TOKEN_ID, + amount: '2', + isMintBaton: true, + }, + }, + ], + ], + returned: [], + }, + { + description: + 'Ignores a utxo if it has the right amount, is not a mint baton, but has the wrong token id', + tokenId: MOCK_TOKEN_ID, + slpUtxos: [ + [ + { value: 546 }, + { + token: { + tokenId: + '2222222222222222222222222222222222222222222222222222222222222222', + amount: '100', + isMintBaton: false, + }, + }, + ], + ], + returned: [], + }, + ], + }, + getNftParentFanTxTargetOutputs: { + expectedReturns: [ + { + description: + 'Gets 19 fan outputs for an NFT1 parent fan tx for max outputs and no change', + fanInputs: [ + { + outpoint: { + txid: '1111111111111111111111111111111111111111111111111111111111111111', + outIdx: 0, + }, + value: 546, + token: { tokenId: MOCK_TOKEN_ID, amount: '19' }, + path: 1899, + }, + ], + returned: [ + { + value: 0, + script: Buffer.from( + '6a04534c500001810453454e44201111111111111111111111111111111111111111111111111111111111111111080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001', + 'hex', + ), + }, + ].concat(Array(19).fill({ value: appConfig.dustSats })), + rawTx: { + hex: '02000000021111111111111111111111111111111111111111111111111111111111111111000000006a473044022053fa3c2142b89d1d9accc3151077f14932aba7fb420679e853ea9ee963e6643c022009db94090c322d36e13b773ba4e6c6aa23ac0070039b26a0de50a5bb28d8e709412103b9fefe35855c7bf75f3132718b2107bb30d0d1f0193fdb8a11f9cb781fc7c921ffffffff4b451a9cdbc0ee92420e5b8179b432fa9af11a9fa835c4aefcd1a5d3882365a8000000006a47304402204ec14c28dc99ca0935730e6f1ac583d9840c9315afca383115be3470c23c6cd60220356e3eb98d0d5646ceab1658338df2eeb095e054f2cd784eb2fc4a715a0d7aca412103b9fefe35855c7bf75f3132718b2107bb30d0d1f0193fdb8a11f9cb781fc7c921ffffffff150000000000000000d96a04534c500001810453454e4420111111111111111111111111111111111111111111111111111111111111111108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000122020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac20170f00000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac00000000', + txid: '80af1add3a89d32cd836cdbeace7afbb8ee6f4b9a888f60e736e511b23689ba3', + }, + }, + { + description: + 'Gets 18 fan outputs for an NFT1 parent fan tx for max outputs if we have change', + fanInputs: [ + { + outpoint: { + txid: '1111111111111111111111111111111111111111111111111111111111111112', + outIdx: 0, + }, + value: 546, + token: { tokenId: MOCK_TOKEN_ID, amount: '100' }, + path: 1899, + }, + ], + returned: [ + { + value: 0, + script: Buffer.from( + '6a04534c500001810453454e44201111111111111111111111111111111111111111111111111111111111111111080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000051', + 'hex', + ), + }, + ].concat(Array(19).fill({ value: appConfig.dustSats })), + rawTx: { + hex: '02000000021211111111111111111111111111111111111111111111111111111111111111000000006b483045022100a4011a1788dffca772bed4182823cd190f7674bcf5c57d5e8c762e805e9ac22d022013250011c63fdb68315ea5a2b80fbf123a46562f1ec927c867410ab3ee4755b5412103b9fefe35855c7bf75f3132718b2107bb30d0d1f0193fdb8a11f9cb781fc7c921ffffffff4b451a9cdbc0ee92420e5b8179b432fa9af11a9fa835c4aefcd1a5d3882365a8000000006a47304402206091174f82d36d71666f6fab1f43360f14c142ad4b27ce137e121ffc9bcbd18f02201d963d5c304ea960b07386c420aefd41ba34d338ce0233b419db0bb9f2252650412103b9fefe35855c7bf75f3132718b2107bb30d0d1f0193fdb8a11f9cb781fc7c921ffffffff150000000000000000d96a04534c500001810453454e4420111111111111111111111111111111111111111111111111111111111111111108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000005122020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac20170f00000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac00000000', + txid: '499056f011f10fc7f96552aac58dbcf689c9863bcb6554811a3604f04921dddf', + }, + }, + { + description: + 'Gets token amount fan outputs for an NFT1 parent fan tx if user has less than 19 of this token left', + fanInputs: [ + { + outpoint: { + txid: '1111111111111111111111111111111111111111111111111111111111111114', + outIdx: 0, + }, + value: 546, + token: { tokenId: MOCK_TOKEN_ID, amount: '12' }, + path: 1899, + }, + ], + returned: [ + { + value: 0, + script: Buffer.from( + '6a04534c500001810453454e44201111111111111111111111111111111111111111111111111111111111111111080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001080000000000000001', + 'hex', + ), + }, + ].concat(Array(12).fill({ value: appConfig.dustSats })), + rawTx: { + hex: '02000000021411111111111111111111111111111111111111111111111111111111111111000000006a473044022022fe48be7588746a1b7b9d2d51bc3444faf0b9287f2696e40d64452781f6d579022038334e277efe4df55fae355c4e00b294ff66bcdf3d4ec9d674c3535039510549412103b9fefe35855c7bf75f3132718b2107bb30d0d1f0193fdb8a11f9cb781fc7c921ffffffff4b451a9cdbc0ee92420e5b8179b432fa9af11a9fa835c4aefcd1a5d3882365a8000000006b483045022100d2a5579cfc4aaf75d6b3c0c76fb48492e350e8d0ca0bc4452a5450fb0bdd8da20220621dbf3f4965ee43898926a35f4b4488ca7bebd96056644b7eba373246c56967412103b9fefe35855c7bf75f3132718b2107bb30d0d1f0193fdb8a11f9cb781fc7c921ffffffff0e00000000000000009a6a04534c500001810453454e4420111111111111111111111111111111111111111111111111111111111111111108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000108000000000000000122020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac22020000000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac3b270f00000000001976a914c38232a045a85c84e5733d60e867dcee9ad4b18d88ac00000000', + txid: 'bf0124bae03f652aa464a1edab48696dc7372543e596ad0161273077dcfbd732', + }, + }, + ], + expectedErrors: [ + { + description: 'Throws error if called with no fanInputs', + fanInputs: [], + error: new Error( + 'No eligible inputs for this NFT parent fan tx', + ), + }, + ], + }, }; 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 @@ -11,6 +11,13 @@ // 0xffffffffffffffff export const MAX_MINT_AMOUNT_TOKEN_SATOSHIS = '18446744073709551615'; +// SLP1 supports up to 19 outputs +// https://github.com/simpleledger/slp-specifications/blob/master/slp-token-type-1.md#send--transfer +// This value is defined by the spec, i.e. an SLP1 SEND tx with more outputs is invalid +// Rationale behind spec decision: OP_RETURN is limited to 223 bytes. A 19-output SLP Send tx requires +// 217 bytes in the OP_RETURN. Each output requires an additional 9 bytes (1 byte pushdata, 8 bytes value) +// So any more than 19 would be over the currently prevailing 223-byte OP_RETURN limit +const SLP1_SEND_MAX_OUTPUTS = 19; // Note: NFT1 spec supports non-zero decimals, but 0 is recommended // Cashtab follows the recommendation and will only mint 0-decimal NFT1 parent tokens @@ -19,6 +26,10 @@ // For SLPv1 Mint txs, Cashtab always puts the mint baton at mintBatonVout 2 const CASHTAB_SLP1_MINT_MINTBATON_VOUT = 2; +// 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'; + /** * Get targetOutput for a SLP v1 genesis tx * @param {object} genesisConfig object containing token info for genesis tx @@ -528,3 +539,97 @@ return targetOutputs; }; + +/** + * Get inputs to make an NFT parent fan tx + * We need to make fan txs as minting an NFT1 child nft requires burning exactly 1 of the parent + * Well, the spec will let you do it if you burn more than one. But our users can be expected + * to appreciate our economy in this regard. * + * In practice, we are getting token utxos for tokenId that are not mint batons and have qty > 1 + * @param {string} tokenId tokenId of NFT1 Parent (aka Group aka Collection) token we want to mint child NFTs for + * @param {CashtabUtxo[]} slpUtxos What Cashtab stores at the wallet.state.slpUtxos key + * @returns {CashtabUtxo[]} + */ +export const getNftParentFanInputs = (tokenId, slpUtxos) => { + return slpUtxos.filter(utxo => { + // UTXO matches the token ID + return ( + utxo.token?.tokenId === tokenId && + // UTXO is not already of the correct qty to be an NftParentFanInput + // Note: not expected to ever have this amount be '0' unless we have a mint baton + // If we do (somehow) get a 0 amount, no harm using it as an input...should + // consolidate it away anyhow + utxo.token?.amount !== SLP1_NFT_CHILD_GENESIS_AMOUNT && + // UTXO is not a minting baton + utxo.token?.isMintBaton === false + ); + }); +}; + +/** + * Get target outputs for an NFT 1 parent fan tx, + * i.e. a tx that creates as many token utxos as possible with amount === 1 + * @param {CashtabUtxo[]} fanInputs result from getNftParentFanUtxos + * @returns {Array} array of target outputs, including script output at index 0, and dust outputs after + * as many as 19 dust outputs + */ +export const getNftParentFanTxTargetOutputs = fanInputs => { + if (fanInputs.length === 0) { + throw new Error('No eligible inputs for this NFT parent fan tx'); + } + // Iterate over eligible nft parent fan utxos (the output of getNftParentFanUtxos) + // Create as many minting utxos as possible in one tx (per spec, 19) + const fanInputsThisTx = []; + let totalInputAmount = new BN(0); + let maxOutputs = false; + for (const input of fanInputs) { + fanInputsThisTx.push(input); + // Note that all fanInputs have token.amount + totalInputAmount = totalInputAmount.plus(new BN(input.token.amount)); + if (totalInputAmount.gte(SLP1_SEND_MAX_OUTPUTS)) { + maxOutputs = true; + // We have enough inputs to create max outputs + break; + } + } + // Note we may also get here with a qty less than SLP1_SEND_MAX_OUTPUTS + // The user might not have 19 NFTs left to mint for this token + // Note we do not need a BN for fanOutputs. totalInputAmount needs BN because it could be enormous. + // But here, fanOutputs will be less than or equal to 19 + const fanOutputs = maxOutputs + ? SLP1_SEND_MAX_OUTPUTS + : totalInputAmount.toNumber(); + + // We only expect change if we have totalInputAmount of > 19 + const change = maxOutputs + ? totalInputAmount.minus(SLP1_SEND_MAX_OUTPUTS) + : new BN(0); + const hasChange = change.gt(0); + + // We send amount 1 to as many outputs as we can + // If we have change and maxOutputs === true, this is 18 + // Otherwise it's fanOutputs, which could be 19, or less if the user does not have 19 of this token left + const MAX_OUTPUTS_IF_CHANGE = SLP1_SEND_MAX_OUTPUTS - 1; + const sendAmounts = Array( + hasChange && maxOutputs ? MAX_OUTPUTS_IF_CHANGE : fanOutputs, + ).fill(new BN(1)); + if (hasChange) { + // Add change as the last output bc it feels weird adding it first + sendAmounts.push(change); + } + + const targetOutputs = []; + const script = NFT1.Group.send(fanInputs[0].token.tokenId, sendAmounts); + + // Add OP_RETURN output at index 0 + targetOutputs.push({ value: 0, script }); + + // Add dust outputs + // Note that Cashtab will add the creating wallet's change address + // to any output not including an address or script key + for (let i = 0; i < fanOutputs; i += 1) { + targetOutputs.push({ value: appConfig.dustSats }); + } + + return targetOutputs; +}; diff --git a/cashtab/src/transactions/__tests__/index.test.js b/cashtab/src/transactions/__tests__/index.test.js --- a/cashtab/src/transactions/__tests__/index.test.js +++ b/cashtab/src/transactions/__tests__/index.test.js @@ -19,186 +19,233 @@ ignoreUnspendableUtxosVectors, sendSlp, } from '../fixtures/vectors'; - -describe('We can broadcast XEC transactions', () => { - // Unit test for each vector in fixtures for the sendingXecToSingleAddress case - const { txs, errors } = sendXecVectors; - - // Successfully built and broadcast txs - txs.forEach(async tx => { - const { - description, - wallet, - targetOutputs, - feeRate, - chaintipBlockheight, - txid, - hex, - } = tx; - it(`sendXec: ${description}`, async () => { - const chronik = new MockChronikClient(); - chronik.setMock('broadcastTx', { - input: hex, - output: { txid }, - }); - expect( - await sendXec( - chronik, - wallet, - targetOutputs, - feeRate, - chaintipBlockheight, - ), - ).toStrictEqual({ hex, response: { txid } }); - }); - }); - - // Error cases - errors.forEach(async error => { - const { description, wallet, targetOutputs, feeRate, msg, hex } = error; - - it(`sendXec: ${description}`, async () => { - const chronik = new MockChronikClient(); - // e.g. ('block', {input: '', output: ''}) - if (typeof hex !== 'undefined') { - // For error cases that are not thrown until after the tx is successfully built, - // set a tx broadcast error that can be thrown by the broadcasting eCash node +import slpv1Vectors from 'slpv1/fixtures/vectors'; +import { walletWithTokensInNode } from 'transactions/fixtures/mocks'; + +describe('Cashtab functions that build and broadcast rawtxs', () => { + describe('We can broadcast XEC transactions', () => { + // Unit test for each vector in fixtures for the sendingXecToSingleAddress case + const { txs, errors } = sendXecVectors; + + // Successfully built and broadcast txs + txs.forEach(async tx => { + const { + description, + wallet, + targetOutputs, + feeRate, + chaintipBlockheight, + txid, + hex, + } = tx; + it(`sendXec: ${description}`, async () => { + const chronik = new MockChronikClient(); chronik.setMock('broadcastTx', { input: hex, - output: new Error(msg), + output: { txid }, }); - } - - await expect( - sendXec(chronik, wallet, targetOutputs, feeRate), - ).rejects.toThrow(msg); + expect( + await sendXec( + chronik, + wallet, + targetOutputs, + feeRate, + chaintipBlockheight, + ), + ).toStrictEqual({ hex, response: { txid } }); + }); }); - }); -}); -describe('Forming multisend targetOutputs', () => { - // Unit test for each vector in fixtures for the getMultisendTargetOutputs case - const { formedOutputs, errors } = getMultisendTargetOutputsVectors; - - // Successfully built and broadcast txs - formedOutputs.forEach(async formedOutput => { - const { description, userMultisendInput, targetOutputs } = formedOutput; - it(`getMultisendTargetOutputs: ${description}`, () => { - expect(getMultisendTargetOutputs(userMultisendInput)).toStrictEqual( - targetOutputs, - ); + // Error cases + errors.forEach(async error => { + const { description, wallet, targetOutputs, feeRate, msg, hex } = + error; + + it(`sendXec: ${description}`, async () => { + const chronik = new MockChronikClient(); + // e.g. ('block', {input: '', output: ''}) + if (typeof hex !== 'undefined') { + // For error cases that are not thrown until after the tx is successfully built, + // set a tx broadcast error that can be thrown by the broadcasting eCash node + chronik.setMock('broadcastTx', { + input: hex, + output: new Error(msg), + }); + } + + await expect( + sendXec(chronik, wallet, targetOutputs, feeRate), + ).rejects.toThrow(msg); + }); }); }); + describe('Forming multisend targetOutputs', () => { + // Unit test for each vector in fixtures for the getMultisendTargetOutputs case + const { formedOutputs, errors } = getMultisendTargetOutputsVectors; + + // Successfully built and broadcast txs + formedOutputs.forEach(async formedOutput => { + const { description, userMultisendInput, targetOutputs } = + formedOutput; + it(`getMultisendTargetOutputs: ${description}`, () => { + expect( + getMultisendTargetOutputs(userMultisendInput), + ).toStrictEqual(targetOutputs); + }); + }); - // Error cases - errors.forEach(async error => { - const { description, userMultisendInput, msg } = error; + // Error cases + errors.forEach(async error => { + const { description, userMultisendInput, msg } = error; - it(`getMultisendTargetOutputs throws error for: ${description}`, () => { - expect(() => getMultisendTargetOutputs(userMultisendInput)).toThrow( - msg, - ); + it(`getMultisendTargetOutputs throws error for: ${description}`, () => { + expect(() => + getMultisendTargetOutputs(userMultisendInput), + ).toThrow(msg); + }); }); }); -}); - -describe('Ignore unspendable coinbase utxos', () => { - // Unit test for each vector in fixtures for the ignoreUnspendableUtxos case - const { expectedReturns } = ignoreUnspendableUtxosVectors; - - // Successfully built and broadcast txs - expectedReturns.forEach(async formedOutput => { - const { - description, - unfilteredUtxos, - chaintipBlockheight, - spendableUtxos, - } = formedOutput; - it(`ignoreUnspendableUtxos: ${description}`, () => { - expect( - ignoreUnspendableUtxos(unfilteredUtxos, chaintipBlockheight), - ).toStrictEqual(spendableUtxos); + describe('Ignore unspendable coinbase utxos', () => { + // Unit test for each vector in fixtures for the ignoreUnspendableUtxos case + const { expectedReturns } = ignoreUnspendableUtxosVectors; + + // Successfully built and broadcast txs + expectedReturns.forEach(async formedOutput => { + const { + description, + unfilteredUtxos, + chaintipBlockheight, + spendableUtxos, + } = formedOutput; + it(`ignoreUnspendableUtxos: ${description}`, () => { + expect( + ignoreUnspendableUtxos( + unfilteredUtxos, + chaintipBlockheight, + ), + ).toStrictEqual(spendableUtxos); + }); }); }); -}); - -describe('We can create and broadcast SLP v1 SEND and BURN txs from utxos of nng or in-node chronik shape', () => { - // Unit test for each vector in fixtures for the sendingXecToSingleAddress case - const { expectedReturns } = sendSlp; - - // Successfully builds and broadcasts txs for in-node chronik-client-shaped input utxos - expectedReturns.forEach(async tx => { - const { - description, - wallet, - tokenId, - sendQty, - decimals, - sendAmounts, - tokenInputs, - destinationAddress, - feeRate, - chaintipBlockheight, - txid, - hex, - burn, - } = tx; - it(`Build and broadcast an SLP V1 SEND and BURN tx from in-node chronik-client utxos: ${description}`, async () => { - const chronik = new MockChronikClient(); - chronik.setMock('broadcastTx', { - input: hex, - output: { txid }, - }); - chronik.setMock('broadcastTx', { - input: burn.hex, - output: { txid: burn.txid }, - }); - - // Get tokenInputs and sendAmounts - const tokenInputInfo = getSendTokenInputs( - wallet.state.slpUtxos, + describe('We can create and broadcast SLP v1 SEND and BURN txs from utxos of nng or in-node chronik shape', () => { + // Unit test for each vector in fixtures for the sendingXecToSingleAddress case + const { expectedReturns } = sendSlp; + + // Successfully builds and broadcasts txs for in-node chronik-client-shaped input utxos + expectedReturns.forEach(async tx => { + const { + description, + wallet, tokenId, sendQty, decimals, - ); - - expect(tokenInputInfo.tokenInputs).toStrictEqual(tokenInputs); - expect(tokenInputInfo.sendAmounts).toStrictEqual(sendAmounts); - - // Get the targetOutputs - const tokenSendTargetOutputs = getSlpSendTargetOutputs( - tokenInputInfo, + sendAmounts, + tokenInputs, destinationAddress, - ); - - // SLP v1 SEND - expect( - await sendXec( - chronik, - wallet, - tokenSendTargetOutputs, - feeRate, - chaintipBlockheight, - tokenInputInfo.tokenInputs, - ), - ).toStrictEqual({ hex, response: { txid } }); - - // SLP v1 BURN - - // Get the targetOutputs - const tokenBurnTargetOutputs = - getSlpBurnTargetOutputs(tokenInputInfo); - - expect( - await sendXec( - chronik, - wallet, - tokenBurnTargetOutputs, // This is the only difference between SEND and BURN - feeRate, - chaintipBlockheight, - tokenInputInfo.tokenInputs, - ), - ).toStrictEqual({ hex: burn.hex, response: { txid: burn.txid } }); + feeRate, + chaintipBlockheight, + txid, + hex, + burn, + } = tx; + it(`Build and broadcast an SLP V1 SEND and BURN tx from in-node chronik-client utxos: ${description}`, async () => { + const chronik = new MockChronikClient(); + chronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); + chronik.setMock('broadcastTx', { + input: burn.hex, + output: { txid: burn.txid }, + }); + + // Get tokenInputs and sendAmounts + const tokenInputInfo = getSendTokenInputs( + wallet.state.slpUtxos, + tokenId, + sendQty, + decimals, + ); + + expect(tokenInputInfo.tokenInputs).toStrictEqual(tokenInputs); + expect(tokenInputInfo.sendAmounts).toStrictEqual(sendAmounts); + + // Get the targetOutputs + const tokenSendTargetOutputs = getSlpSendTargetOutputs( + tokenInputInfo, + destinationAddress, + ); + + // SLP v1 SEND + expect( + await sendXec( + chronik, + wallet, + tokenSendTargetOutputs, + feeRate, + chaintipBlockheight, + tokenInputInfo.tokenInputs, + ), + ).toStrictEqual({ hex, response: { txid } }); + + // SLP v1 BURN + + // Get the targetOutputs + const tokenBurnTargetOutputs = + getSlpBurnTargetOutputs(tokenInputInfo); + + expect( + await sendXec( + chronik, + wallet, + tokenBurnTargetOutputs, // This is the only difference between SEND and BURN + feeRate, + chaintipBlockheight, + tokenInputInfo.tokenInputs, + ), + ).toStrictEqual({ + hex: burn.hex, + response: { txid: burn.txid }, + }); + }); + }); + }); + describe('We can build and broadcast NFT1 parent fan-out txs', () => { + const { expectedReturns } = slpv1Vectors.getNftParentFanTxTargetOutputs; + const CHAINTIP = 800000; + const FEE_RATE = 1; + + // Successfully built and broadcast txs + expectedReturns.forEach(async tx => { + const { description, fanInputs, returned, rawTx } = tx; + + const { hex, txid } = rawTx; + it(`sendXec: ${description}`, async () => { + const chronik = new MockChronikClient(); + chronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); + expect( + await sendXec( + chronik, + { + ...walletWithTokensInNode, + state: { + ...walletWithTokensInNode.state, + slpUtxos: [ + ...walletWithTokensInNode.state.slpUtxos, + ...fanInputs, + ], + }, + }, + returned, + FEE_RATE, + CHAINTIP, + fanInputs, + ), + ).toStrictEqual({ hex, response: { txid } }); + }); }); }); });