diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "3.42.5", + "version": "3.43.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "2.42.7", + "version": "2.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.42.7", + "version": "2.43.0", "dependencies": { "@bitgo/utxo-lib": "^9.33.0", "@zxing/browser": "^0.1.4", diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "2.42.7", + "version": "2.43.0", "private": true, "scripts": { "start": "node scripts/start.js", diff --git a/cashtab/src/components/Send/SendXec.js b/cashtab/src/components/Send/SendXec.js --- a/cashtab/src/components/Send/SendXec.js +++ b/cashtab/src/components/Send/SendXec.js @@ -18,7 +18,7 @@ isValidXecSendAmount, getOpReturnRawError, } from 'validation'; -import { ConvertAmount, AlertMsg, TxLink } from 'components/Common/Atoms'; +import { ConvertAmount, AlertMsg, TxLink, Info } from 'components/Common/Atoms'; import { getWalletState } from 'utils/cashMethods'; import { sendXec, @@ -96,17 +96,17 @@ flex-direction: column; justify-content: center; `; -const ParsedOpReturnRawRow = styled.div` +const ParsedBip21InfoRow = styled.div` display: flex; flex-direction: column; word-break: break-word; `; -const ParsedOpReturnRawLabel = styled.div` +const ParsedBip21InfoLabel = styled.div` color: ${props => props.theme.contrast}; text-align: left; width: 100%; `; -const ParsedOpReturnRaw = styled.div` +const ParsedBip21Info = styled.div` background-color: #fff2f0; border-radius: 12px; color: ${props => props.theme.eCashBlue}; @@ -225,6 +225,21 @@ const [airdropFlag, setAirdropFlag] = useState(false); + // Shorthand variable for bip21 multiple outputs + const isBip21MultipleOutputs = + typeof parsedAddressInput.parsedAdditionalXecOutputs !== 'undefined' && + parsedAddressInput.parsedAdditionalXecOutputs.error === false && + parsedAddressInput.parsedAdditionalXecOutputs.value !== null; + + // Shorthand this calc as well as it is used in multiple spots + const bip21MultipleOutputsFormattedTotalSendXec = isBip21MultipleOutputs + ? parsedAddressInput.parsedAdditionalXecOutputs.value.reduce( + (accumulator, addressAmountArray) => + accumulator + parseFloat(addressAmountArray[1]), + parseFloat(parsedAddressInput.amount.value), + ) + : 0; + const userLocale = getUserLocale(navigator); const clearInputForms = () => { setFormData(emptyFormData); @@ -479,7 +494,19 @@ value: satoshisToSend, }); - Event('Send.js', 'Send', selectedCurrency); + if (isBip21MultipleOutputs) { + parsedAddressInput.parsedAdditionalXecOutputs.value.forEach( + ([addr, amount]) => { + targetOutputs.push({ + script: Script.fromAddress(addr), + value: toSatoshis(amount), + }); + }, + ); + Event('Send.js', 'SendToMany', selectedCurrency); + } else { + Event('Send.js', 'Send', selectedCurrency); + } } // Send and notify @@ -586,9 +613,19 @@ renderedSendToError = parsedAddressInput.op_return_raw.error; } + // Handle errors in secondary addr&amount params + if ( + renderedSendToError === false && + 'parsedAdditionalXecOutputs' in parsedAddressInput && + typeof parsedAddressInput.parsedAdditionalXecOutputs.error === + 'string' + ) { + renderedSendToError = + parsedAddressInput.parsedAdditionalXecOutputs.error; + } + setSendAddressError(renderedSendToError); - // Set amount if it's in the query string if ('amount' in parsedAddressInput) { // Set currency to non-fiat setSelectedCurrency(appConfig.ticker); @@ -605,15 +642,21 @@ // Set op_return_raw if it's in the query string if ('op_return_raw' in parsedAddressInput) { - // Turn on sendWithOpReturnRaw - setSendWithOpReturnRaw(true); - // Update the op_return_raw field and trigger its validation - handleOpReturnRawInput({ - target: { - name: 'opReturnRaw', - value: parsedAddressInput.op_return_raw.value, - }, - }); + // In general, we want to show the op_return_raw value even if there is an error, + // so the user can see what it is + // However in some cases, like duplicate op_return_raw, we do not even have a value to show + // So, only render if we have a renderable value + if (typeof parsedAddressInput.op_return_raw.value === 'string') { + // Turn on sendWithOpReturnRaw + setSendWithOpReturnRaw(true); + // Update the op_return_raw field and trigger its validation + handleOpReturnRawInput({ + target: { + name: 'opReturnRaw', + value: parsedAddressInput.op_return_raw.value, + }, + }); + } } // Set address field to user input @@ -799,10 +842,25 @@ .symbol } ` : '$ ' - } ${(fiatPrice * formData.amount).toLocaleString(userLocale, { - minimumFractionDigits: appConfig.cashDecimals, - maximumFractionDigits: appConfig.cashDecimals, - })} ${ + } ${ + isBip21MultipleOutputs + ? `${( + fiatPrice * + bip21MultipleOutputsFormattedTotalSendXec + ).toLocaleString(userLocale, { + minimumFractionDigits: appConfig.cashDecimals, + maximumFractionDigits: appConfig.cashDecimals, + })}` + : `${(fiatPrice * formData.amount).toLocaleString( + userLocale, + { + minimumFractionDigits: + appConfig.cashDecimals, + maximumFractionDigits: + appConfig.cashDecimals, + }, + )}` + } ${ settings && settings.fiatCurrency ? settings.fiatCurrency.toUpperCase() : 'USD' @@ -905,27 +963,48 @@ </TxLink> </AliasAddressPreviewLabel> </InputAndAliasPreviewHolder> - <SendXecInput - name="amount" - value={formData.amount} - selectValue={selectedCurrency} - selectDisabled={ - 'amount' in parsedAddressInput || txInfoFromUrl - } - inputDisabled={ - priceApiError || - (txInfoFromUrl !== false && - 'value' in txInfoFromUrl && - txInfoFromUrl.value !== 'null' && - txInfoFromUrl.value !== 'undefined') || - 'amount' in parsedAddressInput - } - fiatCode={settings.fiatCurrency.toUpperCase()} - error={sendAmountError} - handleInput={handleAmountChange} - handleSelect={handleSelectedCurrencyChange} - handleOnMax={onMax} - /> + {isBip21MultipleOutputs ? ( + <Info> + <b> + BIP21: Sending{' '} + {bip21MultipleOutputsFormattedTotalSendXec.toLocaleString( + userLocale, + { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }, + )}{' '} + XEC to{' '} + {parsedAddressInput + .parsedAdditionalXecOutputs.value + .length + 1}{' '} + outputs + </b> + </Info> + ) : ( + <SendXecInput + name="amount" + value={formData.amount} + selectValue={selectedCurrency} + selectDisabled={ + 'amount' in parsedAddressInput || + txInfoFromUrl + } + inputDisabled={ + priceApiError || + (txInfoFromUrl !== false && + 'value' in txInfoFromUrl && + txInfoFromUrl.value !== 'null' && + txInfoFromUrl.value !== 'undefined') || + 'amount' in parsedAddressInput + } + fiatCode={settings.fiatCurrency.toUpperCase()} + error={sendAmountError} + handleInput={handleAmountChange} + handleSelect={handleSelectedCurrencyChange} + handleOnMax={onMax} + /> + )} </SendToOneInputForm> </SendToOneHolder> {priceApiError && ( @@ -1053,20 +1132,67 @@ {opReturnRawError === false && formData.opReturnRaw !== '' && ( <SendXecRow> - <ParsedOpReturnRawRow> - <ParsedOpReturnRawLabel> + <ParsedBip21InfoRow> + <ParsedBip21InfoLabel> Parsed op_return_raw - </ParsedOpReturnRawLabel> - <ParsedOpReturnRaw> + </ParsedBip21InfoLabel> + <ParsedBip21Info> <b>{parsedOpReturnRaw.protocol}</b> <br /> {parsedOpReturnRaw.data} - </ParsedOpReturnRaw> - </ParsedOpReturnRawRow> + </ParsedBip21Info> + </ParsedBip21InfoRow> </SendXecRow> )} </> )} + {isBip21MultipleOutputs && ( + <SendXecRow> + <ParsedBip21InfoRow> + <ParsedBip21InfoLabel> + Parsed BIP21 outputs + </ParsedBip21InfoLabel> + <ParsedBip21Info> + <ol> + <li + title={parsedAddressInput.address.value} + >{`${parsedAddressInput.address.value.slice( + 6, + 12, + )}...${parsedAddressInput.address.value.slice( + -6, + )}, ${parseFloat( + parsedAddressInput.amount.value, + ).toLocaleString(userLocale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} XEC`}</li> + {Array.from( + parsedAddressInput + .parsedAdditionalXecOutputs.value, + ).map(([addr, amount], index) => { + return ( + <li + key={index} + title={addr} + >{`${addr.slice( + 6, + 12, + )}...${addr.slice( + -6, + )}, ${parseFloat( + amount, + ).toLocaleString(userLocale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} XEC`}</li> + ); + })} + </ol> + </ParsedBip21Info> + </ParsedBip21InfoRow> + </SendXecRow> + )} </SendXecForm> <AmountPreviewCtn> @@ -1078,6 +1204,17 @@ ' ' + selectedCurrency} </LocaleFormattedValue> + ) : isBip21MultipleOutputs ? ( + <LocaleFormattedValue> + {bip21MultipleOutputsFormattedTotalSendXec.toLocaleString( + userLocale, + { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }, + )}{' '} + XEC + </LocaleFormattedValue> ) : ( <LocaleFormattedValue> {!isNaN(formData.amount) diff --git a/cashtab/src/components/Send/__tests__/SendByUrlParams.test.js b/cashtab/src/components/Send/__tests__/SendByUrlParams.test.js --- a/cashtab/src/components/Send/__tests__/SendByUrlParams.test.js +++ b/cashtab/src/components/Send/__tests__/SendByUrlParams.test.js @@ -679,15 +679,17 @@ true, ); - // Amount input is not updated as the bip21 query is invalid - expect(amountInputEl).toHaveValue(null); + // Amount input is updated despite invalid bip21 query, so the user can see the amount + expect(amountInputEl).toHaveValue(amount); - // The amount input is not disabled because it is not set by the invalid bip21 query string - expect(amountInputEl).toHaveProperty('disabled', false); + // The amount input is disabled because it was set by a bip21 query string and URL routing + expect(amountInputEl).toHaveProperty('disabled', true); // We get the expected validation error expect( - screen.getByText('bip21 parameters may not appear more than once'), + screen.getByText( + 'The op_return_raw param may not appear more than once', + ), ).toBeInTheDocument(); // The Send button is disabled @@ -756,4 +758,123 @@ expect(screen.queryByText(amountErr)).not.toBeInTheDocument(); } }); + it('bip21 param - valid bip21 param with amount, op_return_raw, and additional output with amount is parsed as expected', async () => { + const destinationAddress = + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv'; + const amount = 110; + const secondOutputAddr = + 'ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5'; + const secondOutputAmount = 5.5; + const op_return_raw = + '0470617977202562dd05deda1c101b10562527bcd6bec20268fb94eed01843ba049cd774bec1'; + const bip21Str = `${destinationAddress}?amount=${amount}&op_return_raw=${op_return_raw}&addr=${secondOutputAddr}&amount=${secondOutputAmount}`; + const hash = `#/send?bip21=${bip21Str}`; + // ?bip21=ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&op_return_raw=0470617977202562dd05deda1c101b10562527bcd6bec20268fb94eed01843ba049cd774bec1&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.50 + Object.defineProperty(window, 'location', { + value: { + hash, + }, + writable: true, + }); + // Mock the app with context at the Send screen + const mockedChronik = await initializeCashtabStateForTests( + walletWithXecAndTokens, + localforage, + ); + render(<CashtabTestWrapper chronik={mockedChronik} route="/send" />); + + // Wait for the app to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // Wait for balance to be loaded + expect(await screen.findByText('9,513.12 XEC')).toBeInTheDocument(); + + const addressInputEl = screen.getByPlaceholderText('Address'); + + // The "Send to Many" switch is disabled + expect(screen.getByTitle('Toggle Multisend')).toHaveProperty( + 'disabled', + true, + ); + + // The 'Send To' input field has this address as a value + await waitFor(() => expect(addressInputEl).toHaveValue(bip21Str)); + + // The address input is disabled for app txs with bip21 strings + // Note it is NOT disabled for txs where the user inputs the bip21 string + // This is covered in SendXec.test.js + expect(addressInputEl).toHaveProperty('disabled', true); + + // The "Send to Many" switch is disabled + expect(screen.getByTitle('Toggle Multisend')).toHaveProperty( + 'disabled', + true, + ); + + // Amount input is not displayed + expect(screen.queryByPlaceholderText('Amount')).not.toBeInTheDocument(); + + // Instead, we see a bip21 output summary + expect( + screen.getByText('BIP21: Sending 115.50 XEC to 2 outputs'), + ).toBeInTheDocument(); + + const opReturnRawInput = screen.getByPlaceholderText( + `(Advanced) Enter raw hex to be included with this transaction's OP_RETURN`, + ); + + // The op_return_raw input is populated with this op_return_raw + expect(opReturnRawInput).toHaveValue(op_return_raw); + + // The op_return_raw input is disabled + expect(opReturnRawInput).toHaveProperty('disabled', true); + + // No addr validation errors on load + for (const addrErr of SEND_ADDRESS_VALIDATION_ERRORS) { + expect(screen.queryByText(addrErr)).not.toBeInTheDocument(); + } + // No amount validation errors on load + for (const amountErr of SEND_AMOUNT_VALIDATION_ERRORS) { + expect(screen.queryByText(amountErr)).not.toBeInTheDocument(); + } + + // The op_return_raw switch is disabled because we have txInfoFromUrl + expect(screen.getByTitle('Toggle op_return_raw')).toHaveProperty( + 'disabled', + true, + ); + + // The op_return_raw switch is checked because it is set by txInfoFromUrl + expect(screen.getByTitle('Toggle op_return_raw')).toHaveProperty( + 'checked', + true, + ); + + // We see the preview of this op_return_raw + expect(screen.getByText('Parsed op_return_raw')).toBeInTheDocument(); + + // The Send button is enabled as we have valid address and amount params + expect( + await screen.findByRole('button', { name: 'Send' }), + ).not.toHaveStyle('cursor: not-allowed'); + + // The Cashtab Msg switch is disabled because we have txInfoFromUrl + expect(screen.getByTitle('Toggle Cashtab Msg')).toHaveProperty( + 'disabled', + true, + ); + + // We see expected summary of additional bip21 outputs + expect(screen.getByText('Parsed BIP21 outputs')).toBeInTheDocument(); + expect( + screen.getByText(`qr6lws...6lyxkv, 110.00 XEC`), + ).toBeInTheDocument(); + expect( + screen.getByText(`qp4dxt...qkrjp5, 5.50 XEC`), + ).toBeInTheDocument(); + }); }); diff --git a/cashtab/src/components/Send/__tests__/SendXec.test.js b/cashtab/src/components/Send/__tests__/SendXec.test.js --- a/cashtab/src/components/Send/__tests__/SendXec.test.js +++ b/cashtab/src/components/Send/__tests__/SendXec.test.js @@ -1772,4 +1772,152 @@ ), ); }); + it('Entering a valid bip21 query string with multiple outputs and op_return_raw will correctly populate UI fields, and the tx can be sent', async () => { + // Mock the app with context at the Send screen + const mockedChronik = await initializeCashtabStateForTests( + walletWithXecAndTokens, + localforage, + ); + + // Can check in electrum for opreturn and multiple outputs + const hex = + '0200000001fe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006441d95dfbf01e233d19684fd525d1cc39eb82a53ebfc97b8f2f9160f418ce863f4360f9fd1d6c182abde1d582ed39c6998ec5e4cdbde1b09736f6abe390a6ab8d8f4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff040000000000000000296a04007461622263617368746162206d6573736167652077697468206f705f72657475726e5f726177a4060000000000001976a9144e532257c01b310b3b5c1fd947c79a72addf852388ac40e20100000000001976a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88acca980c00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; + const txid = + 'f153119862f52dbe765ed5d66a5ff848d0386c5d987af9bef5e49a7e62a2c889'; + mockedChronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); + + render( + <CashtabTestWrapper + chronik={mockedChronik} + ecc={ecc} + route="/send" + />, + ); + + // Wait for the app to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + const addressInputEl = screen.getByPlaceholderText('Address'); + // The user enters a valid BIP21 query string with a valid amount param + const op_return_raw = + '04007461622263617368746162206d6573736167652077697468206f705f72657475726e5f726177'; + const addressInput = `ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6?amount=17&op_return_raw=${op_return_raw}&addr=ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035&amount=1234.56`; + await user.type(addressInputEl, addressInput); + + // The 'Send To' input field has this bip21 query string as a value + expect(addressInputEl).toHaveValue(addressInput); + + // The 'Send To' input field is not disabled + expect(addressInputEl).toHaveProperty('disabled', false); + + // The "Send to Many" switch is disabled + expect(screen.getByTitle('Toggle Multisend')).toHaveProperty( + 'disabled', + true, + ); + + // Because we have multiple outputs, the amount input is not displayed + expect(screen.queryByPlaceholderText('Amount')).not.toBeInTheDocument(); + + // Instead, we see a summary of total outputs and XEC to be sent + expect( + screen.getByText('BIP21: Sending 1,251.56 XEC to 2 outputs'), + ).toBeInTheDocument(); + + const opReturnRawInput = screen.getByPlaceholderText( + `(Advanced) Enter raw hex to be included with this transaction's OP_RETURN`, + ); + + // The op_return_raw input is populated with this op_return_raw + expect(opReturnRawInput).toHaveValue(op_return_raw); + + // The op_return_raw input is disabled + expect(opReturnRawInput).toHaveProperty('disabled', true); + + // We see expected data in the op_return_raw preview + expect( + screen.getByText('cashtab message with op_return_raw'), + ).toBeInTheDocument(); + + // No addr validation errors on load + for (const addrErr of SEND_ADDRESS_VALIDATION_ERRORS) { + expect(screen.queryByText(addrErr)).not.toBeInTheDocument(); + } + // No amount validation errors on load + for (const amountErr of SEND_AMOUNT_VALIDATION_ERRORS) { + expect(screen.queryByText(amountErr)).not.toBeInTheDocument(); + } + + // The Send button is enabled as we have valid address and amount params + expect(screen.getByRole('button', { name: 'Send' })).not.toHaveStyle( + 'cursor: not-allowed', + ); + + // The Cashtab Msg switch is disabled because op_return_raw is set + expect(screen.getByTitle('Toggle Cashtab Msg')).toHaveProperty( + 'disabled', + true, + ); + + // We see a summary table of addresses and amounts + expect(screen.getByText('Parsed BIP21 outputs')).toBeInTheDocument(); + expect( + screen.getByText('qp89xg...9nhgg6, 17.00 XEC'), + ).toBeInTheDocument(); + expect( + screen.getByText('qz2708...rf5035, 1,234.56 XEC'), + ).toBeInTheDocument(); + + // Click Send + await user.click( + screen.getByRole('button', { name: 'Send' }), + addressInput, + ); + + // Notification is rendered with expected txid?; + const txSuccessNotification = await screen.findByText('eCash sent'); + await waitFor(() => + expect(txSuccessNotification).toHaveAttribute( + 'href', + `${explorer.blockExplorerUrl}/tx/${txid}`, + ), + ); + await waitFor(() => + // The op_return_raw set alert is now removed + expect( + screen.queryByText( + `Hex OP_RETURN "04007461622263617368746162206D6573736167652077697468206F705F72657475726E5F726177" set by BIP21`, + ), + ).not.toBeInTheDocument(), + ); + // The amount input is no longer hidden + expect( + await screen.findByPlaceholderText('Amount'), + ).toBeInTheDocument(), + // Amount input is reset + expect(await screen.findByPlaceholderText('Amount')).toHaveValue( + null, + ), + // The "Send to Many" switch is not disabled + expect(screen.getByTitle('Toggle Multisend')).toHaveProperty( + 'disabled', + false, + ); + + // The 'Send To' input field has been cleared + expect(addressInputEl).toHaveValue(''); + + // The Cashtab Msg switch is not disabled because op_return_raw is not set + expect(screen.getByTitle('Toggle Cashtab Msg')).toHaveProperty( + 'disabled', + false, + ); + }); }); diff --git a/cashtab/src/validation/fixtures/vectors.js b/cashtab/src/validation/fixtures/vectors.js --- a/cashtab/src/validation/fixtures/vectors.js +++ b/cashtab/src/validation/fixtures/vectors.js @@ -709,6 +709,132 @@ queryString: { value: 'amount=125', error: false }, }, }, + // no op_return_raw, additional outputs + { + description: + 'Valid primary address & amount, valid secondary addr & amount', + addressInput: + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.50', + balanceSats: 50000000, + userLocale: appConfig.defaultLocale, + parsedAddressInput: { + address: { + value: 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv', + error: false, + isAlias: false, + }, + amount: { value: '110', error: false }, + parsedAdditionalXecOutputs: { + error: false, + value: [ + [ + 'ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5', + '5.50', + ], + ], + }, + queryString: { + value: 'amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.50', + error: false, + }, + }, + }, + { + description: + 'Valid primary address & amount, invalid secondary addr', + addressInput: + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&addr=someinvalidaddress&amount=5.50', + balanceSats: 50000000, + userLocale: appConfig.defaultLocale, + parsedAddressInput: { + address: { + value: 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv', + error: false, + isAlias: false, + }, + amount: { value: '110', error: false }, + parsedAdditionalXecOutputs: { + error: `Invalid address "someinvalidaddress"`, + value: null, + }, + queryString: { + value: 'amount=110&addr=someinvalidaddress&amount=5.50', + error: false, + }, + }, + }, + { + description: + 'Valid primary address & amount, invalid secondary amount', + addressInput: + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.123', + balanceSats: 50000000, + userLocale: appConfig.defaultLocale, + parsedAddressInput: { + address: { + value: 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv', + error: false, + isAlias: false, + }, + amount: { value: '110', error: false }, + parsedAdditionalXecOutputs: { + error: `Invalid amount 5.123 for address ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5: XEC transactions do not support more than 2 decimal places`, + value: null, + }, + queryString: { + value: 'amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.123', + error: false, + }, + }, + }, + { + description: + 'Valid primary address & amount, valid secondary addr & amount, but the secondary amount param does not directly follow the secondary addr param', + addressInput: + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&op_return_raw=0401020304&amount=5.50', + balanceSats: 50000000, + userLocale: appConfig.defaultLocale, + parsedAddressInput: { + address: { + value: 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv', + error: false, + isAlias: false, + }, + amount: { value: '110', error: false }, + parsedAdditionalXecOutputs: { + error: `No amount key for addr ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5`, + value: null, + }, + queryString: { + value: 'amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&op_return_raw=0401020304&amount=5.50', + error: false, + }, + }, + }, + { + description: + 'Valid primary address & amount, valid secondary addr, but no corresponding amount param', + addressInput: + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5', + balanceSats: 50000000, + userLocale: appConfig.defaultLocale, + parsedAddressInput: { + address: { + value: 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv', + error: false, + isAlias: false, + }, + amount: { value: '110', error: false }, + parsedAdditionalXecOutputs: { + error: `No amount key for addr ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5`, + value: null, + }, + queryString: { + value: 'amount=110&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5', + error: false, + }, + }, + }, // opreturn param only { @@ -801,7 +927,40 @@ }, }, }, - + // Both op_return_raw and amount params, with an additional output + { + description: + 'Valid amount and op_return_raw params and valid second output', + addressInput: + 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv?amount=110&op_return_raw=0470617977202562dd05deda1c101b10562527bcd6bec20268fb94eed01843ba049cd774bec1&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.50', + balanceSats: 50000000, + userLocale: appConfig.defaultLocale, + parsedAddressInput: { + address: { + value: 'ecash:qr6lws9uwmjkkaau4w956lugs9nlg9hudqs26lyxkv', + error: false, + isAlias: false, + }, + amount: { value: '110', error: false }, + op_return_raw: { + value: '0470617977202562dd05deda1c101b10562527bcd6bec20268fb94eed01843ba049cd774bec1', + error: false, + }, + parsedAdditionalXecOutputs: { + error: false, + value: [ + [ + 'ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5', + '5.50', + ], + ], + }, + queryString: { + value: 'amount=110&op_return_raw=0470617977202562dd05deda1c101b10562527bcd6bec20268fb94eed01843ba049cd774bec1&addr=ecash:qp4dxtmjlkc6upn29hh9pr2u8rlznwxeqqy0qkrjp5&amount=5.50', + error: false, + }, + }, + }, { description: 'invalid querystring (unsupported params)', addressInput: @@ -822,7 +981,8 @@ }, // Querystring errors where no params can be returned { - description: 'Invalid queryString, repeated param', + description: + 'Invalid queryString, repeated amount param without corresponding address', addressInput: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx?amount=123.45&amount=678.9', balanceSats: 50000000, @@ -833,9 +993,13 @@ error: false, isAlias: false, }, + amount: { + value: null, + error: 'Duplicated amount param without matching address', + }, queryString: { value: 'amount=123.45&amount=678.9', - error: 'bip21 parameters may not appear more than once', + error: 'The amount param appears without a corresponding addr param', }, }, }, @@ -851,9 +1015,13 @@ error: false, isAlias: false, }, + op_return_raw: { + error: 'Duplicated op_return_raw param', + value: null, + }, queryString: { value: 'op_return_raw=042e786563000474657374150095e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d&op_return_raw=042e786563000474657374150095e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d', - error: `bip21 parameters may not appear more than once`, + error: `The op_return_raw param may not appear more than once`, }, }, }, diff --git a/cashtab/src/validation/index.js b/cashtab/src/validation/index.js --- a/cashtab/src/validation/index.js +++ b/cashtab/src/validation/index.js @@ -651,6 +651,7 @@ * For now, Cashtab supports only * amount - amount to be sent in XEC * opreturn - raw hex for opreturn output + * additional addr & amount - multiple outputs for XEC txs * @param {number} balanceSats user wallet balance in satoshis * @param {string} userLocale navigator.language if available, or default value if not * @returns {object} addressInfo. Object with parsed params designed for use in Send.js @@ -714,31 +715,92 @@ // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams const addrParams = new URLSearchParams(queryString); - // Check for duplicated params - const duplicatedParams = - new Set(addrParams.keys()).size !== - Array.from(addrParams.keys()).length; + const supportedParams = ['amount', 'op_return_raw', 'addr']; - if (duplicatedParams) { - // In this case, we can't pass any values back for supported params, - // without changing the shape of addressInfo - parsedAddressInput.queryString.error = `bip21 parameters may not appear more than once`; - return parsedAddressInput; - } + // Iterate over params to check for valid and/or invalid params + // Set a flag -- the first time we see the 'amount' param, it is for the bip21 starting address + let firstAmount = true; + // Set a flag -- per spec, nth outputs must be addr=<address> followed immediately by amount= for that address + let precedingParamAddr = false; + + // Tempting to make additionalXecOutputs a map of address => amount + // However, we want to support the use case of multiple outputs of different amounts going to the same address + const additionalXecOutputs = []; + // Flag as any duplication of this param is off spec + let opReturnRawOccurred = false; + for (const [key, value] of addrParams) { + if (precedingParamAddr !== false) { + // If the preceding param was addr, then this has to be amount, otherwise off spec + if (key !== 'amount') { + parsedAddressInput.parsedAdditionalXecOutputs.error = `No amount key for addr ${precedingParamAddr}`; + return parsedAddressInput; + } + // Validate the amount + const isValidXecSendAmountOrErrorMsg = isValidXecSendAmount( + value, + balanceSats, + userLocale, + ); + if (isValidXecSendAmountOrErrorMsg !== true) { + parsedAddressInput.parsedAdditionalXecOutputs.error = `Invalid amount ${value} for address ${precedingParamAddr}: ${isValidXecSendAmountOrErrorMsg}`; + return parsedAddressInput; + } else { + additionalXecOutputs.push([precedingParamAddr, value]); + } - const supportedParams = ['amount', 'op_return_raw']; + // Reset the flag + precedingParamAddr = false; - // Iterate over params to check for valid and/or invalid params - for (const paramKeyValue of addrParams) { - const paramKey = paramKeyValue[0]; - if (!supportedParams.includes(paramKey)) { + // Go to the next param + continue; + } + + if (!supportedParams.includes(key)) { // queryString error // Keep parsing for other params though - parsedAddressInput.queryString.error = `Unsupported param "${paramKey}"`; + parsedAddressInput.queryString.error = `Unsupported param "${key}"`; + } + if (key === 'addr') { + // nth output address param + // So, we will return a parsedAdditionalXecOutputs key + parsedAddressInput.parsedAdditionalXecOutputs = { + value: null, + error: false, + }; + + const nthAddress = value; + // address validation + // Note: for now, Cashtab only supports valid cash addresses for secondary outputs + // TODO support aliases + const isValidNthAddress = cashaddr.isValidCashAddress( + nthAddress, + 'ecash', + ); + if (!isValidNthAddress) { + // + // If your address is not a valid address and not a valid alias format + parsedAddressInput.parsedAdditionalXecOutputs.error = `Invalid address "${nthAddress}"`; + // We do not return a value for parsedAdditionalXecOutputs if there is a validation error + return parsedAddressInput; + } + // set precedingParamAddr flag to the address + // In this way we can validate bip21 spec and set the amount for this address by the next param + precedingParamAddr = nthAddress; } - if (paramKey === 'amount') { + if (key === 'amount') { + if (!firstAmount) { + // We should only get to this block for the first amount + // If we get here otherwise, it means we are missing the corresponding 'addr' param for this amount + // Set a query string error + parsedAddressInput.queryString.error = `The amount param appears without a corresponding addr param`; + // Do not return an amount value, since it is ambiguous + parsedAddressInput.amount.value = null; + parsedAddressInput.amount.error = `Duplicated amount param without matching address`; + // Stop parsing + return parsedAddressInput; + } // Handle Cashtab-supported bip21 param 'amount' - const amount = paramKeyValue[1]; + const amount = value; parsedAddressInput.amount = { value: amount, error: false }; const validXecSendAmount = isValidXecSendAmount( @@ -750,10 +812,25 @@ // If the result of isValidXecSendAmount is not true, it is an error msg explaining wy parsedAddressInput.amount.error = validXecSendAmount; } + + if (firstAmount) { + // Unset the firstAmount flag so that we correctly parse nth outputs + firstAmount = false; + } } - if (paramKey === 'op_return_raw') { + if (key === 'op_return_raw') { + if (opReturnRawOccurred) { + // Set a query string error + parsedAddressInput.queryString.error = `The op_return_raw param may not appear more than once`; + // Do not return an op_return_raw value, since it is ambiguous + parsedAddressInput.op_return_raw.value = null; + parsedAddressInput.op_return_raw.error = `Duplicated op_return_raw param`; + // Stop parsing + return parsedAddressInput; + } + opReturnRawOccurred = true; // Handle Cashtab-supported bip21 param 'op_return_raw' - const opreturnParam = paramKeyValue[1]; + const opreturnParam = value; parsedAddressInput.op_return_raw = { value: opreturnParam, error: false, @@ -765,6 +842,17 @@ } } } + + // Catch a bip21 syntax error where the LAST param was addr + if (precedingParamAddr !== false) { + parsedAddressInput.parsedAdditionalXecOutputs.error = `No amount key for addr ${precedingParamAddr}`; + return parsedAddressInput; + } + if (additionalXecOutputs.length > 0) { + // If we have secondary outputs, include them + parsedAddressInput.parsedAdditionalXecOutputs.value = + additionalXecOutputs; + } } return parsedAddressInput;