diff --git a/cashtab/src/utils/__tests__/validation.test.js b/cashtab/src/utils/__tests__/validation.test.js index ac1bd175c..ea6a1d9f0 100644 --- a/cashtab/src/utils/__tests__/validation.test.js +++ b/cashtab/src/utils/__tests__/validation.test.js @@ -1,1043 +1,1050 @@ import { shouldRejectAmountInput, fiatToCrypto, isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, isValidCashtabSettings, isValidXecAddress, isValidBchAddress, isValidNewWalletNameLength, isValidEtokenAddress, isValidXecSendAmount, isValidEtokenBurnAmount, isValidTokenId, isValidXecAirdrop, isValidAirdropOutputsArray, isValidAirdropExclusionArray, isValidContactList, parseInvalidSettingsForMigration, parseInvalidCashtabCacheForMigration, isValidCashtabCache, validateMnemonic, isValidAliasString, isProbablyNotAScam, isValidRecipient, isValidSideshiftObj, isValidMultiSendUserInput, } from '../validation'; import aliasSettings from 'config/alias'; import { fromSatoshisToXec } from 'utils/cashMethods'; import { validXecAirdropList, invalidXecAirdropList, invalidXecAirdropListMultipleInvalidValues, invalidXecAirdropListMultipleValidValues, } from '../__mocks__/mockXecAirdropRecipients'; import { validXecAirdropExclusionList, invalidXecAirdropExclusionList, } from '../__mocks__/mockXecAirdropExclusionList'; import { validCashtabCache, cashtabCacheWithOneBadTokenId, cashtabCacheWithDecimalNotNumber, cashtabCacheWithTokenNameNotString, cashtabCacheWithMissingTokenName, } from 'utils/__mocks__/mockCashtabCache'; import { when } from 'jest-when'; import defaultCashtabCache from 'config/cashtabCache'; import appConfig from 'config/app'; describe('Validation utils', () => { it(`isValidSideshiftObj() returns true for a valid sideshift library object`, () => { const mockSideshift = { show: () => { return true; }, hide: () => { return true; }, addEventListener: () => { return true; }, }; expect(isValidSideshiftObj(mockSideshift)).toBe(true); }); it(`isValidSideshiftObj() returns false if the sideshift library object failed to instantiate`, () => { expect(isValidSideshiftObj(null)).toBe(false); }); it(`isValidSideshiftObj() returns false for an invalid sideshift library object`, () => { const mockSideshift = { show: () => { return true; }, hide: () => { return true; }, addEvenListener: 'not-a-function', }; expect(isValidSideshiftObj(mockSideshift)).toBe(false); }); it(`isValidRecipient() returns true for a valid and registered alias input`, async () => { const mockRegisteredAliasResponse = { alias: 'cbdc', address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', txid: 'f7d71433af9a4e0081ea60349becf2a60efed8890df7c3e8e079b3427f51d5ea', blockheight: 802515, }; const fetchUrl = `${aliasSettings.aliasServerBaseUrl}/alias/${mockRegisteredAliasResponse.alias}`; // mock the fetch call to alias-server's '/alias' endpoint global.fetch = jest.fn(); when(fetch) .calledWith(fetchUrl) .mockResolvedValue({ json: () => Promise.resolve(mockRegisteredAliasResponse), }); expect(await isValidRecipient('cbdc.xec')).toBe(true); }); it(`isValidRecipient() returns false for a valid but unregistered alias input`, async () => { const mockUnregisteredAliasResponse = { alias: 'koush', isRegistered: false, registrationFeeSats: 554, processedBlockheight: 803421, }; const fetchUrl = `${aliasSettings.aliasServerBaseUrl}/alias/${mockUnregisteredAliasResponse.alias}`; // mock the fetch call to alias-server's '/alias' endpoint global.fetch = jest.fn(); when(fetch) .calledWith(fetchUrl) .mockResolvedValue({ json: () => Promise.resolve(mockUnregisteredAliasResponse), }); expect(await isValidRecipient('koush.xec')).toBe(false); }); it(`isValidRecipient() returns false for an invalid eCash address / alias input`, async () => { expect(await isValidRecipient('notvalid')).toBe(false); }); it(`isValidRecipient() returns true for a valid eCash address`, async () => { expect( await isValidRecipient( 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', ), ).toBe(true); }); it(`isValidRecipient() returns true for a valid prefix-less eCash address`, async () => { expect( await isValidRecipient( 'qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', ), ).toBe(true); }); it(`isValidAliasString() returns true for a valid lowercase alphanumeric input`, () => { expect(isValidAliasString('jasdf3873')).toBe(true); }); it(`isValidAliasString() returns false for an uppercase alphanumeric input`, () => { expect(isValidAliasString('jasDf3873')).toBe(false); }); it(`isValidAliasString() returns false for a non-english input`, () => { expect(isValidAliasString('Glück')).toBe(false); }); it(`isValidAliasString() returns false for an emoji input`, () => { expect(isValidAliasString('😉')).toBe(false); }); it(`isValidAliasString() returns false for a special character input`, () => { expect(isValidAliasString('( ͡° ͜ʖ ͡°)')).toBe(false); }); it(`isValidAliasString() returns false for a zero width character input`, () => { expect(isValidAliasString('')).toBe(false); }); it(`isValidAliasString() returns false for a valid alphanumeric input with spaces`, () => { expect(isValidAliasString('jasdf3873 ')).toBe(false); }); it(`isValidAliasString() returns false for a valid alphanumeric input with symbols`, () => { expect(isValidAliasString('jasdf3873@#')).toBe(false); }); it(`validateMnemonic() returns true for a valid mnemonic`, () => { const mnemonic = 'labor tail bulb distance estate collect lecture into smile differ yard legal'; expect(validateMnemonic(mnemonic)).toBe(true); }); it(`validateMnemonic() returns false for an invalid mnemonic`, () => { const mnemonic = 'labor tail bulb not valid collect lecture into smile differ yard legal'; expect(validateMnemonic(mnemonic)).toBe(false); }); it(`validateMnemonic() returns false for an empty string mnemonic`, () => { const mnemonic = ''; expect(validateMnemonic(mnemonic)).toBe(false); }); it(`Returns 'false' if ${appConfig.ticker} send amount is a valid send amount`, () => { expect(shouldRejectAmountInput('10', appConfig.ticker, 20.0, 300)).toBe( false, ); }); it(`Returns 'false' if ${appConfig.ticker} send amount is a valid send amount in USD`, () => { // Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 15 BCHA or $300 expect(shouldRejectAmountInput('170', 'USD', 20.0, 15)).toBe(false); }); it(`Returns not a number if ${appConfig.ticker} send amount is not a number`, () => { const expectedValidationError = `Amount must be a number`; expect( shouldRejectAmountInput('Not a number', appConfig.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns amount must be greater than 0 if ${appConfig.ticker} send amount is 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect(shouldRejectAmountInput('0', appConfig.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns amount must be greater than 0 if ${appConfig.ticker} send amount is less than 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect( shouldRejectAmountInput('-0.031', appConfig.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns balance error if ${appConfig.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${appConfig.ticker} balance`; expect(shouldRejectAmountInput('17', appConfig.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns balance error if ${appConfig.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${appConfig.ticker} balance`; expect(shouldRejectAmountInput('17', appConfig.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns error if ${ appConfig.ticker } send amount is less than ${fromSatoshisToXec( appConfig.dustSats, ).toString()} minimum`, () => { const expectedValidationError = `Send amount must be at least ${fromSatoshisToXec( appConfig.dustSats, ).toString()} ${appConfig.ticker}`; expect( shouldRejectAmountInput( ( fromSatoshisToXec(appConfig.dustSats).toString() - 0.00000001 ).toString(), appConfig.ticker, 20.0, 3, ), ).toBe(expectedValidationError); }); it(`Returns error if ${ appConfig.ticker } send amount is less than ${fromSatoshisToXec( appConfig.dustSats, ).toString()} minimum in fiat currency`, () => { const expectedValidationError = `Send amount must be at least ${fromSatoshisToXec( appConfig.dustSats, ).toString()} ${appConfig.ticker}`; expect( shouldRejectAmountInput('0.0000005', 'USD', 0.00005, 1000000), ).toBe(expectedValidationError); }); it(`Returns balance error if ${appConfig.ticker} send amount is greater than user balance with fiat currency selected`, () => { const expectedValidationError = `Amount cannot exceed your ${appConfig.ticker} balance`; // Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 5 BCHA or $100 expect(shouldRejectAmountInput('170', 'USD', 20.0, 5)).toBe( expectedValidationError, ); }); it(`Returns precision error if ${appConfig.ticker} send amount has more than ${appConfig.cashDecimals} decimal places`, () => { const expectedValidationError = `${appConfig.ticker} transactions do not support more than ${appConfig.cashDecimals} decimal places`; expect( shouldRejectAmountInput('17.123456789', appConfig.ticker, 20.0, 35), ).toBe(expectedValidationError); }); it(`Returns expected crypto amount with ${appConfig.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.323134234923423, 8)).toBe( '0.53989295', ); }); it(`Returns expected crypto amount with ${appConfig.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.323134234923423, 2)).toBe( '0.54', ); }); it(`Returns expected crypto amount with ${appConfig.cashDecimals} decimals of precision even if inputs have lower precision`, () => { expect(fiatToCrypto('10.94', 10, 8)).toBe('1.09400000'); }); it(`Accepts a valid ${appConfig.tokenTicker} token name`, () => { expect(isValidTokenName('Valid token name')).toBe(true); }); it(`Accepts a valid ${appConfig.tokenTicker} token name that is a stringified number`, () => { expect(isValidTokenName('123456789')).toBe(true); }); it(`Rejects ${appConfig.tokenTicker} token name if longer than 68 characters`, () => { expect( isValidTokenName( 'This token name is not valid because it is longer than 68 characters which is really pretty long for a token name when you think about it and all', ), ).toBe(false); }); it(`Rejects ${appConfig.tokenTicker} token name if empty string`, () => { expect(isValidTokenName('')).toBe(false); }); it(`Accepts a 4-char ${appConfig.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('DOGG')).toBe(true); }); it(`Accepts a 12-char ${appConfig.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('123456789123')).toBe(true); }); it(`Rejects ${appConfig.tokenTicker} token ticker if empty string`, () => { expect(isValidTokenTicker('')).toBe(false); }); it(`Rejects ${appConfig.tokenTicker} token ticker if > 12 chars`, () => { expect(isValidTokenTicker('1234567891234')).toBe(false); }); it(`Accepts tokenDecimals if zero`, () => { expect(isValidTokenDecimals('0')).toBe(true); }); it(`Accepts tokenDecimals if between 0 and 9 inclusive`, () => { expect(isValidTokenDecimals('9')).toBe(true); }); it(`Rejects tokenDecimals if empty string`, () => { expect(isValidTokenDecimals('')).toBe(false); }); it(`Rejects tokenDecimals if non-integer`, () => { expect(isValidTokenDecimals('1.7')).toBe(false); }); it(`Accepts tokenDecimals initial genesis quantity at minimum amount for 3 decimal places`, () => { expect(isValidTokenInitialQty('0.001', '3')).toBe(true); }); it(`Accepts initial genesis quantity at minimum amount for 9 decimal places`, () => { expect(isValidTokenInitialQty('0.000000001', '9')).toBe(true); }); it(`Accepts initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('1000', '0')).toBe(true); }); it(`Accepts highest possible initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('99999999999.999999999', '9')).toBe(true); }); it(`Accepts initial genesis quantity if decimal places equal tokenDecimals`, () => { expect(isValidTokenInitialQty('0.123', '3')).toBe(true); }); it(`Accepts initial genesis quantity if decimal places are less than tokenDecimals`, () => { expect(isValidTokenInitialQty('0.12345', '9')).toBe(true); }); it(`Rejects initial genesis quantity of zero`, () => { expect(isValidTokenInitialQty('0', '9')).toBe(false); }); it(`Rejects initial genesis quantity if tokenDecimals is not valid`, () => { expect(isValidTokenInitialQty('0', '')).toBe(false); }); it(`Rejects initial genesis quantity if 100 billion or higher`, () => { expect(isValidTokenInitialQty('100000000000', '0')).toBe(false); }); it(`Rejects initial genesis quantity if it has more decimal places than tokenDecimals`, () => { expect(isValidTokenInitialQty('1.5', '0')).toBe(false); }); it(`Accepts a valid ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('cashtabapp.com')).toBe(true); }); it(`Accepts a valid ${appConfig.tokenTicker} token document URL including special URL characters`, () => { expect(isValidTokenDocumentUrl('https://cashtabapp.com/')).toBe(true); }); it(`Accepts a blank string as a valid ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('')).toBe(true); }); it(`Rejects ${appConfig.tokenTicker} token name if longer than 68 characters`, () => { expect( isValidTokenDocumentUrl( 'http://www.ThisTokenDocumentUrlIsActuallyMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchTooLong.com/', ), ).toBe(false); }); it(`Accepts a domain input with https protocol as ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('https://google.com')).toBe(true); }); it(`Accepts a domain input with http protocol as ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('http://test.com')).toBe(true); }); it(`Accepts a domain input with a primary and secondary top level domain as ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('http://test.co.uk')).toBe(true); }); it(`Accepts a domain input with just a subdomain as ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('www.test.co.uk')).toBe(true); }); it(`Rejects a domain input with no top level domain, protocol or subdomain ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('mywebsite')).toBe(false); }); it(`Rejects a domain input as numbers ${appConfig.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl(12345)).toBe(false); }); it(`Recognizes the default cashtabCache object as valid`, () => { expect(isValidCashtabCache(defaultCashtabCache)).toBe(true); }); it(`Recognizes a valid cashtabCache object`, () => { expect(isValidCashtabCache(validCashtabCache)).toBe(true); }); it(`Rejects a cashtabCache object if one token id is invalid`, () => { expect(isValidCashtabCache(cashtabCacheWithOneBadTokenId)).toBe(false); }); it(`Rejects a cashtabCache object if decimals is not a number`, () => { expect(isValidCashtabCache(cashtabCacheWithDecimalNotNumber)).toBe( false, ); }); it(`Rejects a cashtabCache object if tokenName is not a string`, () => { expect(isValidCashtabCache(cashtabCacheWithTokenNameNotString)).toBe( false, ); }); it(`Rejects a cashtabCache object if tokenName is missing`, () => { expect(isValidCashtabCache(cashtabCacheWithMissingTokenName)).toBe( false, ); }); it(`Recognizes a valid cashtab settings object`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'usd', sendModal: false, autoCameraOn: true, hideMessagesFromUnknownSenders: true, balanceVisible: false, }), ).toBe(true); }); it(`Rejects a cashtab settings object for an unsupported currency`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'xau', sendModal: false }), ).toBe(false); }); it(`Rejects a corrupted cashtab settings object for an unsupported currency`, () => { expect( isValidCashtabSettings({ fiatCurrencyWrongLabel: 'usd', sendModal: false, }), ).toBe(false); }); it(`Rejects a valid fiatCurrency setting but undefined sendModal setting`, () => { expect(isValidCashtabSettings({ fiatCurrency: 'usd' })).toBe(false); }); it(`Rejects a valid fiatCurrency setting but invalid sendModal setting`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'usd', sendModal: 'NOTVALID', }), ).toBe(false); }); it(`isValidXecAddress correctly validates a valid XEC address with ecash: prefix`, () => { const addr = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidXecAddress(addr)).toBe(true); }); it(`isValidXecAddress correctly validates a valid XEC address without ecash: prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidXecAddress(addr)).toBe(true); }); it(`isValidXecAddress rejects a valid legacy address`, () => { const addr = '1Efd9z9GRVJK2r73nUpFmBnsKUmfXNm2y2'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid bitcoincash: address`, () => { const addr = 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid etoken: address with prefix`, () => { const addr = 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid etoken: address without prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid simpleledger: address with prefix`, () => { const addr = 'simpleledger:qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid simpleledger: address without prefix`, () => { const addr = 'qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects an invalid address`, () => { const addr = 'wtf is this definitely not an address'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a null input`, () => { const addr = null; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects an empty string input`, () => { const addr = ''; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidBchAddress correctly validates a valid BCH address with bitcoincash: prefix`, () => { const addr = 'bitcoincash:qzvydd4n3lm3xv62cx078nu9rg0e3srmqqkm80dnl6'; expect(isValidBchAddress(addr)).toBe(true); }); it(`isValidBchAddress correctly validates a valid BCH address without bitcoincash: prefix`, () => { const addr = 'qzvydd4n3lm3xv62cx078nu9rg0e3srmqqkm80dnl6'; expect(isValidBchAddress(addr)).toBe(true); }); it(`isValidBchAddress rejects a valid legacy address`, () => { const addr = '1Efd9z9GRVJK2r73nUpFmBnsKUmfXNm2y2'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid ecash: address`, () => { const addr = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid ecash: address without the ecash prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid etoken: address with prefix`, () => { const addr = 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid etoken: address without prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid simpleledger: address with prefix`, () => { const addr = 'simpleledger:qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid simpleledger: address without prefix`, () => { const addr = 'qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects an invalid address`, () => { const addr = 'wtf is this definitely not an address'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a null input`, () => { const addr = null; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects an empty string input`, () => { const addr = ''; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid XEC address with ecash: prefix`, () => { const addr = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid XEC address without ecash: prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid legacy address`, () => { const addr = '1Efd9z9GRVJK2r73nUpFmBnsKUmfXNm2y2'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid bitcoincash: address`, () => { const addr = 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress correctly validates a valid etoken: address with prefix`, () => { const addr = 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidEtokenAddress(addr)).toBe(true); }); it(`isValidEtokenAddress correctly validates a valid etoken: address without prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidEtokenAddress(addr)).toBe(true); }); it(`isValidEtokenAddress rejects a valid simpleledger: address with prefix`, () => { const addr = 'simpleledger:qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid simpleledger: address without prefix`, () => { const addr = 'qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects an invalid address`, () => { const addr = 'wtf is this definitely not an address'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a null input`, () => { const addr = null; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects an empty string input`, () => { const addr = ''; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidXecSendAmount accepts the dust minimum`, () => { const testXecSendAmount = fromSatoshisToXec( appConfig.dustSats, ).toString(); expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount accepts arbitrary number above dust minimum`, () => { const testXecSendAmount = ( fromSatoshisToXec(appConfig.dustSats) + 1.75 ).toString(); expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount rejects zero`, () => { const testXecSendAmount = '0'; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount accepts a string with 1 decimal place`, () => { expect(isValidXecSendAmount('100.1')).toBe(true); }); it(`isValidXecSendAmount accepts a string with 2 decimal places`, () => { expect(isValidXecSendAmount('100.12')).toBe(true); }); it(`isValidXecSendAmount rejects a string with more than 2 decimal places`, () => { expect(isValidXecSendAmount('100.123')).toBe(false); }); it(`isValidXecSendAmount rejects a non-number string`, () => { const testXecSendAmount = 'not a number'; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount accepts arbitrary number above dust minimum as a string`, () => { const testXecSendAmount = `${ fromSatoshisToXec(appConfig.dustSats) + 1.75 }`; expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount rejects null`, () => { const testXecSendAmount = null; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount rejects undefined`, () => { const testXecSendAmount = undefined; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount rejects a value including non-numerical characters`, () => { const testXecSendAmount = '12a17'; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount rejects a value including decimal marker that is not a period`, () => { const testXecSendAmount = '12500,17'; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidEtokenBurnAmount rejects null`, () => { const testEtokenBurnAmount = null; expect(isValidEtokenBurnAmount(testEtokenBurnAmount)).toBe(false); }); it(`isValidEtokenBurnAmount rejects undefined`, () => { const testEtokenBurnAmount = undefined; expect(isValidEtokenBurnAmount(testEtokenBurnAmount)).toBe(false); }); it(`isValidEtokenBurnAmount rejects a burn amount that is 0`, () => { const testEtokenBurnAmount = 0; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(false); }); it(`isValidEtokenBurnAmount rejects a burn amount that is negative`, () => { const testEtokenBurnAmount = -50; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(false); }); it(`isValidEtokenBurnAmount rejects a burn amount that is more than the maxAmount param`, () => { const testEtokenBurnAmount = 1000; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(false); }); it(`isValidEtokenBurnAmount accepts a valid burn amount`, () => { const testEtokenBurnAmount = 50; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(true); }); it(`isValidEtokenBurnAmount accepts a valid burn amount with decimal points`, () => { const testEtokenBurnAmount = 10.545454; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(true); }); it(`isValidEtokenBurnAmount accepts a valid burn amount that is the same as the maxAmount`, () => { const testEtokenBurnAmount = 100; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(true); }); it(`isValidTokenId accepts valid token ID that is 64 chars in length`, () => { const testValidTokenId = '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e'; expect(isValidTokenId(testValidTokenId)).toBe(true); }); it(`isValidTokenId rejects a token ID that is less than 64 chars in length`, () => { const testValidTokenId = '111111thisisaninvalidtokenid'; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects a token ID that is more than 64 chars in length`, () => { const testValidTokenId = '111111111c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e'; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects a token ID number that is 64 digits in length`, () => { // eslint-disable-next-line no-loss-of-precision const testValidTokenId = 8912345678912345678912345678912345678912345678912345678912345679; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects null`, () => { const testValidTokenId = null; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects undefined`, () => { const testValidTokenId = undefined; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects empty string`, () => { const testValidTokenId = ''; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects special character input`, () => { const testValidTokenId = '^&$%@&^$@&%$!'; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects non-alphanumeric input`, () => { const testValidTokenId = 99999999999; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidXecAirdrop accepts valid Total Airdrop Amount`, () => { const testAirdropTotal = '1000000'; expect(isValidXecAirdrop(testAirdropTotal)).toBe(true); }); it(`isValidXecAirdrop rejects null`, () => { const testAirdropTotal = null; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidXecAirdrop rejects undefined`, () => { const testAirdropTotal = undefined; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidXecAirdrop rejects empty string`, () => { const testAirdropTotal = ''; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidTokenId rejects an alphanumeric input`, () => { const testAirdropTotal = 'a73hsyujs3737'; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidTokenId rejects a number !> 0 in string format`, () => { const testAirdropTotal = '0'; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidAirdropOutputsArray accepts an airdrop list with valid XEC values`, () => { // Tools.js logic removes the EOF newline before validation const outputArray = validXecAirdropList.substring( 0, validXecAirdropList.length - 1, ); expect(isValidAirdropOutputsArray(outputArray)).toBe(true); }); it(`isValidAirdropOutputsArray rejects an airdrop list with invalid XEC values`, () => { // Tools.js logic removes the EOF newline before validation const outputArray = invalidXecAirdropList.substring( 0, invalidXecAirdropList.length - 1, ); expect(isValidAirdropOutputsArray(outputArray)).toBe(false); }); it(`isValidAirdropOutputsArray rejects null`, () => { const testAirdropListValues = null; expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false); }); it(`isValidAirdropOutputsArray rejects undefined`, () => { const testAirdropListValues = undefined; expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false); }); it(`isValidAirdropOutputsArray rejects empty string`, () => { const testAirdropListValues = ''; expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false); }); it(`isValidAirdropOutputsArray rejects an airdrop list with multiple invalid XEC values per row`, () => { // Tools.js logic removes the EOF newline before validation const addressStringArray = invalidXecAirdropListMultipleInvalidValues.substring( 0, invalidXecAirdropListMultipleInvalidValues.length - 1, ); expect(isValidAirdropOutputsArray(addressStringArray)).toBe(false); }); it(`isValidAirdropOutputsArray rejects an airdrop list with multiple valid XEC values per row`, () => { // Tools.js logic removes the EOF newline before validation const addressStringArray = invalidXecAirdropListMultipleValidValues.substring( 0, invalidXecAirdropListMultipleValidValues.length - 1, ); expect(isValidAirdropOutputsArray(addressStringArray)).toBe(false); }); it(`isValidAirdropExclusionArray accepts a valid airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray(validXecAirdropExclusionList)).toBe( true, ); }); it(`isValidAirdropExclusionArray rejects an invalid airdrop exclusion list`, () => { expect( isValidAirdropExclusionArray(invalidXecAirdropExclusionList), ).toBe(false); }); it(`isValidAirdropExclusionArray rejects an empty airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray([])).toBe(false); }); it(`isValidAirdropExclusionArray rejects a null airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray(null)).toBe(false); }); it(`isValidContactList accepts default empty contactList`, () => expect(isValidContactList([{}])).toBe(true)); it(`isValidContactList rejects array of more than one empty object`, () => expect(isValidContactList([{}, {}])).toBe(false)); it(`isValidContactList accepts a contact list of length 1 with valid XEC address and name`, () => expect( isValidContactList([ { address: 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', name: 'Alpha', }, ]), ).toBe(true)); it(`isValidContactList accepts a contact list of length > 1 with valid XEC addresses and names`, () => expect( isValidContactList([ { address: 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', name: 'Alpha', }, { address: 'ecash:qpq235n3l3u6ampc8slapapnatwfy446auuv64ylt2', name: 'Beta', }, { address: 'ecash:qz50e58nkeg2ej2f34z6mhwylp6ven8emy8pp52r82', name: 'Gamma', }, ]), ).toBe(true)); it(`isValidContactList rejects a contact list of length > 1 with valid XEC addresses and names but an empty object included`, () => expect( isValidContactList([ {}, { address: 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', name: 'Alpha', }, { address: 'ecash:qpq235n3l3u6ampc8slapapnatwfy446auuv64ylt2', name: 'Beta', }, { address: 'ecash:qz50e58nkeg2ej2f34z6mhwylp6ven8emy8pp52r82', name: 'Gamma', }, ]), ).toBe(false)); it('parseInvalidCashtabCacheForMigration updates an invalid cashtabCache object and keeps existing valid cache params intact', () => expect( parseInvalidCashtabCacheForMigration({ tokenInfoById: { '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e': { decimals: 2, tokenDocumentHash: '', tokenDocumentUrl: 'https://cashtab.com/', tokenId: '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e', tokenName: 'test', tokenTicker: 'TEST', }, 'fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa': { decimals: 2, tokenDocumentHash: '', tokenDocumentUrl: 'https://cashtab.com/', tokenId: 'fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa', tokenName: 'test2', tokenTicker: 'TEST2', }, }, }), ).toStrictEqual({ tokenInfoById: { '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e': { decimals: 2, tokenDocumentHash: '', tokenDocumentUrl: 'https://cashtab.com/', tokenId: '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e', tokenName: 'test', tokenTicker: 'TEST', }, 'fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa': { decimals: 2, tokenDocumentHash: '', tokenDocumentUrl: 'https://cashtab.com/', tokenId: 'fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa', tokenName: 'test2', tokenTicker: 'TEST2', }, }, })); it('parseInvalidCashtabCacheForMigration sets cashtabCache object with no exsting valid cache to default values', () => expect(parseInvalidCashtabCacheForMigration({})).toStrictEqual({ tokenInfoById: {}, })); it('updates an invalid settings object and keeps existing valid settings intact', () => expect( parseInvalidSettingsForMigration({ fiatCurrency: 'gbp', }), ).toStrictEqual({ fiatCurrency: 'gbp', sendModal: false, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, })); it('sets settings object with no exsting valid settings to default values', () => expect(parseInvalidSettingsForMigration({})).toStrictEqual({ fiatCurrency: 'usd', sendModal: false, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, })); it('does nothing if valid settings object is present in localStorage', () => expect( parseInvalidSettingsForMigration({ fiatCurrency: 'brl', sendModal: true, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, }), ).toStrictEqual({ fiatCurrency: 'brl', sendModal: true, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, })); it(`accepts a valid wallet name`, () => { expect(isValidNewWalletNameLength('Apollo')).toBe(true); }); it(`rejects wallet name that is too long`, () => { expect( isValidNewWalletNameLength( 'this string is far too long to be used as a wallet name...', ), ).toBe(false); }); it(`rejects blank string as new wallet name`, () => { expect(isValidNewWalletNameLength('')).toBe(false); }); it(`rejects wallet name of the wrong type`, () => { expect(isValidNewWalletNameLength(['newWalletName'])).toBe(false); }); it(`isProbablyNotAScam recognizes "bitcoin" is probably a scam token name`, () => { expect(isProbablyNotAScam('bitcoin')).toBe(false); }); it(`isProbablyNotAScam recognizes "ebitcoin" is probably a scam token name`, () => { expect(isProbablyNotAScam('ebitcoin')).toBe(false); }); it(`isProbablyNotAScam recognizes "Lido Staked Ether", from coingeckoTop500Names, is probably a scam token name`, () => { expect(isProbablyNotAScam('Lido Staked Ether')).toBe(false); }); it(`isProbablyNotAScam recognizes 'matic-network', from coingeckoTop500Ids, is probably a scam token name`, () => { expect(isProbablyNotAScam('matic-network')).toBe(false); }); it(`isProbablyNotAScam recognizes 'Australian Dollar', from Cashtab supported fiat currencies, is probably a scam token name`, () => { expect(isProbablyNotAScam('Australian Dollar')).toBe(false); }); it(`isProbablyNotAScam recognizes 'ebtc', from bannedTickers, is probably a scam token name`, () => { expect(isProbablyNotAScam('ebtc')).toBe(false); }); it(`isProbablyNotAScam recognizes 'gbp', from bannedTickers, is probably a scam`, () => { expect(isProbablyNotAScam('gbp')).toBe(false); }); it(`isProbablyNotAScam recognizes 'Hong Kong Dollar', from fiatNames, is probably a scam`, () => { expect(isProbablyNotAScam('gbp')).toBe(false); }); it(`isProbablyNotAScam recognizes '₪', from fiat symbols, is probably a scam`, () => { expect(isProbablyNotAScam('₪')).toBe(false); }); it(`isProbablyNotAScam recognizes an ordinary token name as acceptable`, () => { expect(isProbablyNotAScam('just a normal token name')).toBe(true); }); it(`isProbablyNotAScam accepts a token name with fragments of banned potential scam names`, () => { expect( isProbablyNotAScam( 'This token is not Ethereum or bitcoin or USD $', ), ).toBe(true); }); it(`isValidMultiSendUserInput accepts correctly formed multisend output`, () => { expect( isValidMultiSendUserInput( `ecash:qplkmuz3rx480u6vc4xgc0qxnza42p0e7vll6p90wr, 22\necash:qqxrrls4u0znxx2q7e5m4en4z2yjrqgqeucckaerq3, 33\necash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 55`, ), ).toBe(true); }); + it(`isValidMultiSendUserInput accepts correctly formed multisend output even if address has extra spaces`, () => { + expect( + isValidMultiSendUserInput( + ` ecash:qplkmuz3rx480u6vc4xgc0qxnza42p0e7vll6p90wr , 22\necash:qqxrrls4u0znxx2q7e5m4en4z2yjrqgqeucckaerq3, 33\necash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 55`, + ), + ).toBe(true); + }); it(`isValidMultiSendUserInput returns expected error msg for invalid address`, () => { expect( isValidMultiSendUserInput( `ecash:notValid, 22\necash:qqxrrls4u0znxx2q7e5m4en4z2yjrqgqeucckaerq3, 33\necash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 55`, ), ).toBe(`Invalid address "ecash:notValid" at line 1`); }); it(`isValidMultiSendUserInput returns expected error msg for invalid value (dust)`, () => { expect( isValidMultiSendUserInput( `ecash:qplkmuz3rx480u6vc4xgc0qxnza42p0e7vll6p90wr, 1\necash:qqxrrls4u0znxx2q7e5m4en4z2yjrqgqeucckaerq3, 33\necash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 55`, ), ).toBe( `Invalid value 1. Amount must be >= ${( appConfig.dustSats / 100 ).toFixed(2)} XEC and <= 2 decimals.`, ); }); it(`isValidMultiSendUserInput returns expected error msg for invalid value (too many decimals)`, () => { expect( isValidMultiSendUserInput( `ecash:qplkmuz3rx480u6vc4xgc0qxnza42p0e7vll6p90wr, 10.123\necash:qqxrrls4u0znxx2q7e5m4en4z2yjrqgqeucckaerq3, 33\necash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 55`, ), ).toBe( `Invalid value 10.123. Amount must be >= ${( appConfig.dustSats / 100 ).toFixed(2)} XEC and <= 2 decimals.`, ); }); it(`isValidMultiSendUserInput returns expected error msg for a blank input`, () => { expect(isValidMultiSendUserInput(` `)).toBe( `Input must not be blank`, ); }); it(`isValidMultiSendUserInput returns expected error msg for extra spaces on a particular line`, () => { expect( isValidMultiSendUserInput( `\n, ecash:qqxrrls4u0znxx2q7e5m4en4z2yjrqgqeucckaerq3, 33\necash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 55`, ), ).toBe(`Remove empty row at line 1`); }); it(`isValidMultiSendUserInput returns expected error for non-string input`, () => { expect(isValidMultiSendUserInput(undefined)).toBe( `Input must be a string`, ); }); it(`isValidMultiSendUserInput returns expected error msg for input without only an address`, () => { expect( isValidMultiSendUserInput( `ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y`, ), ).toBe(`Line 1 must have address and value, separated by a comma`); }); it(`isValidMultiSendUserInput returns expected error msg if line has more than one comma`, () => { expect( isValidMultiSendUserInput( `ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y, 170,23`, ), ).toBe(`Line 1: Comma can only separate address and value.`); }); }); diff --git a/cashtab/src/utils/cashMethods.js b/cashtab/src/utils/cashMethods.js index 3f228a439..5b86a4571 100644 --- a/cashtab/src/utils/cashMethods.js +++ b/cashtab/src/utils/cashMethods.js @@ -1,1339 +1,1340 @@ import { isValidXecAddress, isValidEtokenAddress, isValidContactList, isValidBchAddress, } from 'utils/validation'; import BigNumber from 'bignumber.js'; import cashaddr from 'ecashaddrjs'; import bs58 from 'bs58'; import * as slpMdm from 'slp-mdm'; import * as utxolib from '@bitgo/utxo-lib'; import { opReturn as opreturnConfig } from 'config/opreturn'; import appConfig from 'config/app'; export const getMessageByteSize = ( msgInputStr, encryptionFlag, encryptedEj, ) => { if (!msgInputStr || msgInputStr.trim() === '') { return 0; } // generate the OP_RETURN script let opReturnData; if (encryptionFlag && encryptedEj) { opReturnData = generateOpReturnScript( msgInputStr, encryptionFlag, // encryption flag false, // airdrop use null, // airdrop use encryptedEj, // serialized encryption data object false, // alias registration flag ); } else { opReturnData = generateOpReturnScript( msgInputStr, encryptionFlag, // encryption use false, // airdrop use null, // airdrop use null, // serialized encryption data object false, // alias registration flag ); } // extract the msg input from the OP_RETURN script and check the backend size const hexString = opReturnData.toString('hex'); // convert to hex const opReturnMsg = parseOpReturn(hexString)[1]; // extract the message const msgInputByteSize = opReturnMsg.length / 2; // calculate the byte size return msgInputByteSize; }; // function is based on BCH-JS' generateBurnOpReturn() however it's been trimmed down for Cashtab use // Reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/slp/tokentype1.js#L217 export const generateBurnOpReturn = (tokenUtxos, burnQty) => { try { if (!tokenUtxos || !burnQty) { throw new Error('Invalid burn token parameter'); } // sendToken component already prevents burning of a value greater than the token utxo total held by the wallet const tokenId = tokenUtxos[0].tokenId; const decimals = tokenUtxos[0].decimals; // account for token decimals const finalBurnTokenQty = new BigNumber(burnQty).times(10 ** decimals); // Calculate the total amount of tokens owned by the wallet. const totalTokens = tokenUtxos.reduce( (tot, txo) => tot.plus(new BigNumber(txo.tokenQty).times(10 ** decimals)), new BigNumber(0), ); // calculate the token change const tokenChange = totalTokens.minus(finalBurnTokenQty); const tokenChangeStr = tokenChange.toString(); // Generate the burn OP_RETURN as a Buffer // No need for separate .send() calls for change and non-change burns as // nil change values do not generate token outputs as the full balance is burnt const script = slpMdm.TokenType1.send(tokenId, [ new slpMdm.BN(tokenChangeStr), ]); return script; } catch (err) { console.log('Error in generateBurnOpReturn(): ' + err); throw err; } }; // Function originally based on BCH-JS' generateSendOpReturn function however trimmed down for Cashtab // Reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/slp/tokentype1.js#L95 export const generateSendOpReturn = (tokenUtxos, sendQty) => { try { if (!tokenUtxos || !sendQty) { throw new Error('Invalid send token parameter'); } const tokenId = tokenUtxos[0].tokenId; const decimals = tokenUtxos[0].decimals; // account for token decimals const finalSendTokenQty = new BigNumber(sendQty).times(10 ** decimals); const finalSendTokenQtyStr = finalSendTokenQty.toString(); // Calculate the total amount of tokens owned by the wallet. const totalTokens = tokenUtxos.reduce( (tot, txo) => tot.plus(new BigNumber(txo.tokenQty).times(10 ** decimals)), new BigNumber(0), ); // calculate token change const tokenChange = totalTokens.minus(finalSendTokenQty); const tokenChangeStr = tokenChange.toString(); // When token change output is required let script, outputs; if (tokenChange > 0) { outputs = 2; // Generate the OP_RETURN as a Buffer. script = slpMdm.TokenType1.send(tokenId, [ new slpMdm.BN(finalSendTokenQtyStr), new slpMdm.BN(tokenChangeStr), ]); } else { // no token change needed outputs = 1; // Generate the OP_RETURN as a Buffer. script = slpMdm.TokenType1.send(tokenId, [ new slpMdm.BN(finalSendTokenQtyStr), ]); } return { script, outputs }; } catch (err) { console.log('Error in generateSendOpReturn(): ' + err); throw err; } }; // function is based on BCH-JS' generateGenesisOpReturn() however it's been trimmed down for Cashtab use // Reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/slp/tokentype1.js#L286 export const generateGenesisOpReturn = configObj => { try { if (!configObj) { throw new Error('Invalid token configuration'); } // adjust initial quantity for token decimals const initialQty = new BigNumber(configObj.initialQty) .times(10 ** configObj.decimals) .toString(); const script = slpMdm.TokenType1.genesis( configObj.ticker, configObj.name, configObj.documentUrl, configObj.documentHash, configObj.decimals, configObj.mintBatonVout, new slpMdm.BN(initialQty), ); return script; } catch (err) { console.log('Error in generateGenesisOpReturn(): ' + err); throw err; } }; export const getUtxoWif = (utxo, wallet) => { if (!wallet) { throw new Error('Invalid wallet parameter'); } const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const wif = accounts .filter(acc => acc.cashAddress === utxo.address) .pop().fundingWif; return wif; }; export const signUtxosByAddress = (inputUtxos, wallet, txBuilder) => { for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const wif = accounts .filter(acc => acc.cashAddress === utxo.address) .pop().fundingWif; const utxoECPair = utxolib.ECPair.fromWIF(wif, utxolib.networks.ecash); // Specify hash type // This should be handled at the utxo-lib level, pending latest published version const hashTypes = { SIGHASH_ALL: 0x01, SIGHASH_FORKID: 0x40, }; txBuilder.sign( i, // vin utxoECPair, // keyPair undefined, // redeemScript hashTypes.SIGHASH_ALL | hashTypes.SIGHASH_FORKID, // hashType parseInt(utxo.value), // value ); } return txBuilder; }; export const getCashtabByteCount = (p2pkhInputCount, p2pkhOutputCount) => { // Simplifying bch-js function for P2PKH txs only, as this is all Cashtab supports for now // https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/bitcoincash.js#L408 // The below magic numbers refer to: // const types = { // inputs: { // 'P2PKH': 148 * 4, // }, // outputs: { // P2PKH: 34 * 4, // }, // }; const inputCount = new BigNumber(p2pkhInputCount); const outputCount = new BigNumber(p2pkhOutputCount); const inputWeight = new BigNumber(148 * 4); const outputWeight = new BigNumber(34 * 4); const nonSegwitWeightConstant = new BigNumber(10 * 4); let totalWeight = new BigNumber(0); totalWeight = totalWeight .plus(inputCount.times(inputWeight)) .plus(outputCount.times(outputWeight)) .plus(nonSegwitWeightConstant); const byteCount = totalWeight.div(4).integerValue(BigNumber.ROUND_CEIL); return Number(byteCount); }; export const calcFee = ( utxos, p2pkhOutputNumber = 2, satoshisPerByte = appConfig.defaultFee, opReturnByteCount = 0, ) => { const byteCount = getCashtabByteCount(utxos.length, p2pkhOutputNumber); const txFee = Math.ceil(satoshisPerByte * (byteCount + opReturnByteCount)); return txFee; }; export const generateTokenTxOutput = ( txBuilder, tokenAction, legacyCashOriginAddress, tokenUtxosBeingSpent = [], // optional - send or burn tx only remainderXecValue = new BigNumber(0), // optional - only if > dust tokenConfigObj = {}, // optional - genesis only tokenRecipientAddress = false, // optional - send tx only tokenAmount = false, // optional - send or burn amount for send/burn tx only ) => { try { if (!tokenAction || !legacyCashOriginAddress || !txBuilder) { throw new Error('Invalid token tx output parameter'); } let script, opReturnObj, destinationAddress; switch (tokenAction) { case 'GENESIS': script = generateGenesisOpReturn(tokenConfigObj); destinationAddress = legacyCashOriginAddress; break; case 'SEND': opReturnObj = generateSendOpReturn( tokenUtxosBeingSpent, tokenAmount.toString(), ); script = opReturnObj.script; destinationAddress = tokenRecipientAddress; break; case 'BURN': script = generateBurnOpReturn( tokenUtxosBeingSpent, tokenAmount, ); destinationAddress = legacyCashOriginAddress; break; default: throw new Error('Invalid token transaction type'); } // OP_RETURN needs to be the first output in the transaction. txBuilder.addOutput(script, 0); // add XEC dust output as fee for genesis, send or burn token output txBuilder.addOutput( cashaddr.toLegacy(destinationAddress), parseInt(appConfig.etokenSats), ); // Return any token change back to the sender for send and burn txs if ( tokenAction !== 'GENESIS' || (opReturnObj && opReturnObj.outputs > 1) ) { // add XEC dust output as fee txBuilder.addOutput( cashaddr.toLegacy(tokenUtxosBeingSpent[0].address), // etoken address parseInt(appConfig.etokenSats), ); } // Send xec change to own address if (remainderXecValue.gte(new BigNumber(appConfig.dustSats))) { txBuilder.addOutput( cashaddr.toLegacy(legacyCashOriginAddress), parseInt(remainderXecValue), ); } } catch (err) { console.log(`generateTokenTxOutput() error: ` + err); throw err; } return txBuilder; }; export const generateTxInput = ( isOneToMany, utxos, txBuilder, destinationAddressAndValueArray, satoshisToSend, feeInSatsPerByte, opReturnByteCount, ) => { let txInputObj = {}; const inputUtxos = []; let txFee = 0; let totalInputUtxoValue = new BigNumber(0); try { if ( (isOneToMany && !destinationAddressAndValueArray) || !utxos || !txBuilder || !satoshisToSend || !feeInSatsPerByte ) { throw new Error('Invalid tx input parameter'); } // A normal tx will have 2 outputs, destination and change // A one to many tx will have n outputs + 1 change output, where n is the number of recipients const txOutputs = isOneToMany ? destinationAddressAndValueArray.length + 1 : 2; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; totalInputUtxoValue = totalInputUtxoValue.plus(utxo.value); const vout = utxo.outpoint.outIdx; const txid = utxo.outpoint.txid; // add input with txid and index of vout txBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee( inputUtxos, txOutputs, feeInSatsPerByte, opReturnByteCount, ); if (totalInputUtxoValue.minus(satoshisToSend).minus(txFee).gte(0)) { break; } } } catch (err) { console.log(`generateTxInput() error: ` + err); throw err; } txInputObj.txBuilder = txBuilder; txInputObj.totalInputUtxoValue = totalInputUtxoValue; txInputObj.inputUtxos = inputUtxos; txInputObj.txFee = txFee; return txInputObj; }; export const generateTokenTxInput = ( 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; let tokenUtxosBeingSpent = []; try { if ( !tokenAction || !totalXecUtxos || (tokenAction !== 'GENESIS' && !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(totalXecInputUtxos, txOutputs, feeInSatsPerByte); remainderXecValue = tokenAction === 'GENESIS' ? totalXecInputUtxoValue .minus(new BigNumber(appConfig.etokenSats)) .minus(new BigNumber(txFee)) : totalXecInputUtxoValue .minus(new BigNumber(appConfig.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 = (inputUtxos, wallet) => { if (!inputUtxos || !wallet) { throw new Error('Invalid getChangeAddressFromWallet input parameter'); } // Assume change address is input address of utxo at index 0 let changeAddress; // Validate address try { changeAddress = inputUtxos[0].address; if ( !isValidXecAddress(changeAddress) && !isValidBchAddress(changeAddress) ) { throw new Error('Invalid change address'); } } catch (err) { throw new Error('Invalid input utxo'); } return changeAddress; }; /** * Get the total XEC amount sent in a one-to-many XEC tx * @param {array} destinationAddressAndValueArray * Array constructed by user input of addresses and values * e.g. [ * "
,