diff --git a/web/cashtab/src/hooks/__mocks__/mockSlpUtxos.js b/web/cashtab/src/hooks/__mocks__/mockSlpUtxos.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/hooks/__mocks__/mockSlpUtxos.js @@ -0,0 +1,77 @@ +export default [ + { + outpoint: { + txid: 'ef5674832011f0cba6a6adcda21c6acd18390844ef6ff16886122295d759ac0b', + outIdx: 1, + }, + blockHeight: 716665, + isCoinbase: false, + value: '546', + slpMeta: { + tokenType: 'FUNGIBLE', + txType: 'SEND', + tokenId: + 'f9eabf94edec18e91f518c6b1e22cc47a7464d005f04a06e65f70be7755c94bc', + }, + slpToken: { + amount: '400', + isMintBaton: false, + }, + network: 'XEC', + address: 'bitcoincash:qq9h6d0a5q65fgywv4ry64x04ep906mdku7ymranw3', + tokenQty: '400', + tokenId: + 'f9eabf94edec18e91f518c6b1e22cc47a7464d005f04a06e65f70be7755c94bc', + decimals: 2, + }, + { + outpoint: { + txid: '8b569d64a7e51d1d3cf1cf2b99d8b34451bbebc7df6b67232e5b770418b0428c', + outIdx: 1, + }, + blockHeight: 724044, + isCoinbase: false, + value: '546', + slpMeta: { + tokenType: 'FUNGIBLE', + txType: 'SEND', + tokenId: + 'f9eabf94edec18e91f518c6b1e22cc47a7464d005f04a06e65f70be7755c94bc', + }, + slpToken: { + amount: '6500', + isMintBaton: false, + }, + network: 'XEC', + address: 'bitcoincash:qq9h6d0a5q65fgywv4ry64x04ep906mdku7ymranw3', + tokenQty: '6500', + tokenId: + 'f9eabf94edec18e91f518c6b1e22cc47a7464d005f04a06e65f70be7755c94bc', + decimals: 2, + }, + { + outpoint: { + txid: '8bb94d0ace61f5e0962a1ffdc72f593a84c1bb221e8ba3f53eb3d1136ff7973d', + outIdx: 1, + }, + blockHeight: 724055, + isCoinbase: false, + value: '546', + slpMeta: { + tokenType: 'FUNGIBLE', + txType: 'SEND', + tokenId: + '9d88a4a74802fa0a0b198fa1f0b0ab7d5b1aca0d8ee90df2a18be6b1939d940c', + }, + slpToken: { + amount: '99998998900', + isMintBaton: false, + }, + network: 'XEC', + address: 'bitcoincash:qq9h6d0a5q65fgywv4ry64x04ep906mdku7ymranw3', + tokenQty: '999989989', + tokenId: + '9d88a4a74802fa0a0b198fa1f0b0ab7d5b1aca0d8ee90df2a18be6b1939d940c', + decimals: 2, + }, +]; diff --git a/web/cashtab/src/utils/__mocks__/mockTxBuilderObj.js b/web/cashtab/src/utils/__mocks__/mockTxBuilderObj.js --- a/web/cashtab/src/utils/__mocks__/mockTxBuilderObj.js +++ b/web/cashtab/src/utils/__mocks__/mockTxBuilderObj.js @@ -474,3 +474,408 @@ bip68: {}, p2shInput: false, }; + +export const mockCreateTokenTxBuilderObj = { + transaction: { + prevTxMap: { + '582dfa42e2778a2e6b7d32fb1bf4cefc0be9d10a36538e9503465df99cd4a60d:0': 0, + 'f80e305c5c09585c67b4f395b153cd206083fdadb8687aa526611bcd381b5239:0': 1, + }, + network: { + hashGenesisBlock: + '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', + port: 8333, + portRpc: 8332, + protocol: { + magic: 3652501241, + }, + seedsDns: [ + 'seed.bitcoinabc.org', + 'seed-abc.bitcoinforks.org', + 'btccash-seeder.bitcoinunlimited.info', + 'seed.bitprim.org', + 'seed.deadalnix.me', + 'seeder.criptolayer.net', + ], + versions: { + bip32: { + private: 76066276, + public: 76067358, + }, + bip44: 145, + private: 128, + public: 0, + scripthash: 5, + messagePrefix: '\u0018BitcoinCash Signed Message:\n', + }, + name: 'BitcoinCash', + per1: 100000000, + unit: 'BCH', + testnet: false, + messagePrefix: '\u0018BitcoinCash Signed Message:\n', + bip32: { + public: 76067358, + private: 76066276, + }, + pubKeyHash: 0, + scriptHash: 5, + wif: 128, + dustThreshold: null, + }, + maximumFeeRate: 2500, + inputs: [{}, {}], + bitcoinCash: true, + tx: { + version: 2, + locktime: 0, + ins: [ + { + hash: { + type: 'Buffer', + data: [ + 88, 45, 250, 66, 226, 119, 138, 46, 107, 125, 50, + 251, 27, 244, 206, 252, 11, 233, 209, 10, 54, 83, + 142, 149, 3, 70, 93, 249, 156, 212, 166, 13, + ], + }, + index: 0, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 248, 14, 48, 92, 92, 9, 88, 92, 103, 180, 243, 149, + 177, 83, 205, 32, 96, 131, 253, 173, 184, 104, 122, + 165, 38, 97, 27, 205, 56, 27, 82, 57, + ], + }, + index: 0, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + ], + outs: [], + }, + }, + DEFAULT_SEQUENCE: 4294967295, + hashTypes: { + SIGHASH_ALL: 1, + SIGHASH_NONE: 2, + SIGHASH_SINGLE: 3, + SIGHASH_ANYONECANPAY: 128, + SIGHASH_BITCOINCASH_BIP143: 64, + ADVANCED_TRANSACTION_MARKER: 0, + ADVANCED_TRANSACTION_FLAG: 1, + }, + signatureAlgorithms: { + ECDSA: 0, + SCHNORR: 1, + }, + bip66: {}, + bip68: {}, + p2shInput: false, +}; + +export const mockSendTokenTxBuilderObj = { + transaction: { + prevTxMap: { + '582dfa42e2778a2e6b7d32fb1bf4cefc0be9d10a36538e9503465df99cd4a60d:0': 0, + 'f80e305c5c09585c67b4f395b153cd206083fdadb8687aa526611bcd381b5239:0': 1, + '0bac59d79522128668f16fef44083918cd6a1ca2cdada6a6cbf01120837456ef:1': 2, + '8c42b01804775b2e23676bdfc7ebbb5144b3d8992bcff13c1d1de5a7649d568b:1': 3, + }, + network: { + hashGenesisBlock: + '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', + port: 8333, + portRpc: 8332, + protocol: { + magic: 3652501241, + }, + seedsDns: [ + 'seed.bitcoinabc.org', + 'seed-abc.bitcoinforks.org', + 'btccash-seeder.bitcoinunlimited.info', + 'seed.bitprim.org', + 'seed.deadalnix.me', + 'seeder.criptolayer.net', + ], + versions: { + bip32: { + private: 76066276, + public: 76067358, + }, + bip44: 145, + private: 128, + public: 0, + scripthash: 5, + messagePrefix: '\u0018BitcoinCash Signed Message:\n', + }, + name: 'BitcoinCash', + per1: 100000000, + unit: 'BCH', + testnet: false, + messagePrefix: '\u0018BitcoinCash Signed Message:\n', + bip32: { + public: 76067358, + private: 76066276, + }, + pubKeyHash: 0, + scriptHash: 5, + wif: 128, + dustThreshold: null, + }, + maximumFeeRate: 2500, + inputs: [{}, {}, {}, {}], + bitcoinCash: true, + tx: { + version: 2, + locktime: 0, + ins: [ + { + hash: { + type: 'Buffer', + data: [ + 88, 45, 250, 66, 226, 119, 138, 46, 107, 125, 50, + 251, 27, 244, 206, 252, 11, 233, 209, 10, 54, 83, + 142, 149, 3, 70, 93, 249, 156, 212, 166, 13, + ], + }, + index: 0, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 248, 14, 48, 92, 92, 9, 88, 92, 103, 180, 243, 149, + 177, 83, 205, 32, 96, 131, 253, 173, 184, 104, 122, + 165, 38, 97, 27, 205, 56, 27, 82, 57, + ], + }, + index: 0, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 11, 172, 89, 215, 149, 34, 18, 134, 104, 241, 111, + 239, 68, 8, 57, 24, 205, 106, 28, 162, 205, 173, + 166, 166, 203, 240, 17, 32, 131, 116, 86, 239, + ], + }, + index: 1, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 140, 66, 176, 24, 4, 119, 91, 46, 35, 103, 107, 223, + 199, 235, 187, 81, 68, 179, 216, 153, 43, 207, 241, + 60, 29, 29, 229, 167, 100, 157, 86, 139, + ], + }, + index: 1, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + ], + outs: [], + }, + }, + DEFAULT_SEQUENCE: 4294967295, + hashTypes: { + SIGHASH_ALL: 1, + SIGHASH_NONE: 2, + SIGHASH_SINGLE: 3, + SIGHASH_ANYONECANPAY: 128, + SIGHASH_BITCOINCASH_BIP143: 64, + ADVANCED_TRANSACTION_MARKER: 0, + ADVANCED_TRANSACTION_FLAG: 1, + }, + signatureAlgorithms: { + ECDSA: 0, + SCHNORR: 1, + }, + bip66: {}, + bip68: {}, + p2shInput: false, +}; + +export const mockBurnTokenTxBuilderObj = { + transaction: { + prevTxMap: { + '582dfa42e2778a2e6b7d32fb1bf4cefc0be9d10a36538e9503465df99cd4a60d:0': 0, + 'f80e305c5c09585c67b4f395b153cd206083fdadb8687aa526611bcd381b5239:0': 1, + '0bac59d79522128668f16fef44083918cd6a1ca2cdada6a6cbf01120837456ef:1': 2, + '8c42b01804775b2e23676bdfc7ebbb5144b3d8992bcff13c1d1de5a7649d568b:1': 3, + }, + network: { + hashGenesisBlock: + '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', + port: 8333, + portRpc: 8332, + protocol: { + magic: 3652501241, + }, + seedsDns: [ + 'seed.bitcoinabc.org', + 'seed-abc.bitcoinforks.org', + 'btccash-seeder.bitcoinunlimited.info', + 'seed.bitprim.org', + 'seed.deadalnix.me', + 'seeder.criptolayer.net', + ], + versions: { + bip32: { + private: 76066276, + public: 76067358, + }, + bip44: 145, + private: 128, + public: 0, + scripthash: 5, + messagePrefix: '\u0018BitcoinCash Signed Message:\n', + }, + name: 'BitcoinCash', + per1: 100000000, + unit: 'BCH', + testnet: false, + messagePrefix: '\u0018BitcoinCash Signed Message:\n', + bip32: { + public: 76067358, + private: 76066276, + }, + pubKeyHash: 0, + scriptHash: 5, + wif: 128, + dustThreshold: null, + }, + maximumFeeRate: 2500, + inputs: [{}, {}, {}, {}], + bitcoinCash: true, + tx: { + version: 2, + locktime: 0, + ins: [ + { + hash: { + type: 'Buffer', + data: [ + 88, 45, 250, 66, 226, 119, 138, 46, 107, 125, 50, + 251, 27, 244, 206, 252, 11, 233, 209, 10, 54, 83, + 142, 149, 3, 70, 93, 249, 156, 212, 166, 13, + ], + }, + index: 0, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 248, 14, 48, 92, 92, 9, 88, 92, 103, 180, 243, 149, + 177, 83, 205, 32, 96, 131, 253, 173, 184, 104, 122, + 165, 38, 97, 27, 205, 56, 27, 82, 57, + ], + }, + index: 0, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 11, 172, 89, 215, 149, 34, 18, 134, 104, 241, 111, + 239, 68, 8, 57, 24, 205, 106, 28, 162, 205, 173, + 166, 166, 203, 240, 17, 32, 131, 116, 86, 239, + ], + }, + index: 1, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + { + hash: { + type: 'Buffer', + data: [ + 140, 66, 176, 24, 4, 119, 91, 46, 35, 103, 107, 223, + 199, 235, 187, 81, 68, 179, 216, 153, 43, 207, 241, + 60, 29, 29, 229, 167, 100, 157, 86, 139, + ], + }, + index: 1, + script: { + type: 'Buffer', + data: [], + }, + sequence: 4294967295, + witness: [], + }, + ], + outs: [], + }, + }, + DEFAULT_SEQUENCE: 4294967295, + hashTypes: { + SIGHASH_ALL: 1, + SIGHASH_NONE: 2, + SIGHASH_SINGLE: 3, + SIGHASH_ANYONECANPAY: 128, + SIGHASH_BITCOINCASH_BIP143: 64, + ADVANCED_TRANSACTION_MARKER: 0, + ADVANCED_TRANSACTION_FLAG: 1, + }, + signatureAlgorithms: { + ECDSA: 0, + SCHNORR: 1, + }, + bip66: {}, + bip68: {}, + p2shInput: false, +}; diff --git a/web/cashtab/src/utils/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js --- a/web/cashtab/src/utils/__tests__/cashMethods.test.js +++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js @@ -20,6 +20,7 @@ generateOpReturnScript, generateTxInput, generateTxOutput, + generateTokenTxInput, signAndBuildTx, fromXecToSatoshis, getWalletBalanceFromUtxos, @@ -85,9 +86,13 @@ unsubscribedWebsocket, } from '../__mocks__/chronikWs'; import mockNonSlpUtxos from '../../hooks/__mocks__/mockNonSlpUtxos'; +import mockSlpUtxos from '../../hooks/__mocks__/mockSlpUtxos'; import { mockOneToOneSendXecTxBuilderObj, mockOneToManySendXecTxBuilderObj, + mockCreateTokenTxBuilderObj, + mockSendTokenTxBuilderObj, + mockBurnTokenTxBuilderObj, } from '../__mocks__/mockTxBuilderObj'; import { mockSingleInputUtxo, @@ -505,6 +510,87 @@ expect(thrownError.message).toStrictEqual('Invalid OP RETURN script input'); }); +it(`generateTokenTxInput() returns a valid object for a valid create token tx`, async () => { + const BCH = new BCHJS(); + let txBuilder = new BCH.TransactionBuilder(); + const tokenId = + '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e'; + const tokenInputObj = generateTokenTxInput( + BCH, + 'GENESIS', + mockNonSlpUtxos, + null, // no slpUtxos used for genesis tx + tokenId, + null, // no token send/burn amount for genesis tx + currency.defaultFee, + txBuilder, + ); + + expect(tokenInputObj.inputXecUtxos).toStrictEqual( + [mockNonSlpUtxos[0]].concat([mockNonSlpUtxos[1]]), + ); + expect(tokenInputObj.txBuilder.toString()).toStrictEqual( + mockCreateTokenTxBuilderObj.toString(), + ); + expect(tokenInputObj.remainderXecValue).toStrictEqual( + new BigNumber(699702), // remainder = tokenInputObj.inputXecUtxos - currency.etokenSats - txFee + ); +}); + +it(`generateTokenTxInput() returns a valid object for a valid send token tx`, async () => { + const BCH = new BCHJS(); + let txBuilder = new BCH.TransactionBuilder(); + const tokenId = mockSlpUtxos[0].tokenId; + + const tokenInputObj = generateTokenTxInput( + BCH, + 'SEND', + mockNonSlpUtxos, + mockSlpUtxos, + tokenId, + new BigNumber(500), // sending 500 of these tokens + currency.defaultFee, + txBuilder, + ); + + expect(tokenInputObj.inputTokenUtxos).toStrictEqual( + [mockSlpUtxos[0]].concat([mockSlpUtxos[1]]), // mockSlpUtxos[0] 400 + mockSlpUtxos[1] 6500 + ); + expect(tokenInputObj.remainderTokenValue).toStrictEqual( + new BigNumber(6400), // token change is mockSlpUtxos[0] 400 + mockSlpUtxos[1] 6500 - [tokenAmount] 500 + ); + expect(tokenInputObj.txBuilder.toString()).toStrictEqual( + mockSendTokenTxBuilderObj.toString(), + ); +}); + +it(`generateTokenTxInput() returns a valid object for a valid burn token tx`, async () => { + const BCH = new BCHJS(); + let txBuilder = new BCH.TransactionBuilder(); + const tokenId = mockSlpUtxos[0].tokenId; + + const tokenInputObj = generateTokenTxInput( + BCH, + 'BURN', + mockNonSlpUtxos, + mockSlpUtxos, + tokenId, + new BigNumber(500), // burning 500 of these tokens + currency.defaultFee, + txBuilder, + ); + + expect(tokenInputObj.inputTokenUtxos).toStrictEqual( + [mockSlpUtxos[0]].concat([mockSlpUtxos[1]]), // mockSlpUtxos[0] 400 + mockSlpUtxos[1] 6500 + ); + expect(tokenInputObj.remainderTokenValue).toStrictEqual( + new BigNumber(6400), // token change is mockSlpUtxos[0] 400 + mockSlpUtxos[1] 6500 - [tokenAmount] 500 + ); + expect(tokenInputObj.txBuilder.toString()).toStrictEqual( + mockBurnTokenTxBuilderObj.toString(), + ); +}); + it(`generateTxInput() returns an input object for a valid one to one XEC tx`, async () => { const BCH = new BCHJS(); const isOneToMany = false; diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -97,6 +97,138 @@ return txInputObj; }; +export const generateTokenTxInput = ( + BCH, + tokenAction, // GENESIS, SEND or BURN + totalXecUtxos, + totalTokenUtxos, + tokenId, + tokenAmount, // optional - only for sending or burning + feeInSatsPerByte, + txBuilder, +) => { + let totalXecInputUtxoValue = new BigNumber(0); + let remainderXecValue = new BigNumber(0); + let remainderTokenValue = new BigNumber(0); + let totalXecInputUtxos = []; + let txFee = 0; + const { calcFee } = useBCH(); + let tokenUtxosBeingSpent = []; + + try { + if ( + !BCH || + !tokenAction || + !totalXecUtxos || + !tokenId || + !feeInSatsPerByte || + !txBuilder + ) { + throw new Error('Invalid token tx input parameter'); + } + + // collate XEC UTXOs for this token tx + const txOutputs = + tokenAction === 'GENESIS' + ? 2 // one for genesis OP_RETURN output and one for change + : 4; // for SEND/BURN token txs see T2645 on why this is not dynamically generated + for (let i = 0; i < totalXecUtxos.length; i++) { + const thisXecUtxo = totalXecUtxos[i]; + totalXecInputUtxoValue = totalXecInputUtxoValue.plus( + new BigNumber(thisXecUtxo.value), + ); + const vout = thisXecUtxo.outpoint.outIdx; + const txid = thisXecUtxo.outpoint.txid; + // add input with txid and index of vout + txBuilder.addInput(txid, vout); + + totalXecInputUtxos.push(thisXecUtxo); + txFee = calcFee( + BCH, + totalXecInputUtxos, + txOutputs, + feeInSatsPerByte, + ); + + remainderXecValue = + tokenAction === 'GENESIS' + ? totalXecInputUtxoValue + .minus(new BigNumber(currency.etokenSats)) + .minus(new BigNumber(txFee)) + : totalXecInputUtxoValue + .minus(new BigNumber(currency.etokenSats * 2)) // one for token send/burn output, one for token change + .minus(new BigNumber(txFee)); + + if (remainderXecValue.gte(0)) { + break; + } + } + + if (remainderXecValue.lt(0)) { + throw new Error(`Insufficient funds`); + } + + let filteredTokenInputUtxos = []; + let finalTokenAmountSpent = new BigNumber(0); + let tokenAmountBeingSpent = new BigNumber(tokenAmount); + + if (tokenAction === 'SEND' || tokenAction === 'BURN') { + // filter for token UTXOs matching the token being sent/burnt + filteredTokenInputUtxos = totalTokenUtxos.filter(utxo => { + if ( + utxo && // UTXO is associated with a token. + utxo.slpMeta.tokenId === tokenId && // UTXO matches the token ID. + !utxo.slpToken.isMintBaton // UTXO is not a minting baton. + ) { + return true; + } + return false; + }); + if (filteredTokenInputUtxos.length === 0) { + throw new Error( + 'No token UTXOs for the specified token could be found.', + ); + } + + // collate token UTXOs to cover the token amount being sent/burnt + for (let i = 0; i < filteredTokenInputUtxos.length; i++) { + finalTokenAmountSpent = finalTokenAmountSpent.plus( + new BigNumber(filteredTokenInputUtxos[i].tokenQty), + ); + txBuilder.addInput( + filteredTokenInputUtxos[i].outpoint.txid, + filteredTokenInputUtxos[i].outpoint.outIdx, + ); + tokenUtxosBeingSpent.push(filteredTokenInputUtxos[i]); + if (tokenAmountBeingSpent.lte(finalTokenAmountSpent)) { + break; + } + } + + // calculate token change + remainderTokenValue = finalTokenAmountSpent.minus( + new BigNumber(tokenAmount), + ); + if (remainderTokenValue.lt(0)) { + throw new Error( + 'Insufficient token UTXOs for the specified token amount.', + ); + } + } + } catch (err) { + console.log(`generateTokenTxInput() error: ` + err); + throw err; + } + + return { + txBuilder: txBuilder, + inputXecUtxos: totalXecInputUtxos, + inputTokenUtxos: tokenUtxosBeingSpent, + remainderXecValue: remainderXecValue, + remainderTokenValue: remainderTokenValue, + }; +}; + export const getChangeAddressFromInputUtxos = (BCH, inputUtxos, wallet) => { if (!BCH || !inputUtxos || !wallet) { throw new Error('Invalid getChangeAddressFromWallet input parameter');