diff --git a/web/cashtab/src/components/Common/Atoms.js b/web/cashtab/src/components/Common/Atoms.js index 84258f309..784425e6e 100644 --- a/web/cashtab/src/components/Common/Atoms.js +++ b/web/cashtab/src/components/Common/Atoms.js @@ -1,84 +1,88 @@ import styled from 'styled-components'; +export const WarningFont = styled.div` + color: ${props => props.theme.wallet.text.primary}; +`; + export const LoadingCtn = styled.div` width: 100%; display: flex; align-items: center; justify-content: center; height: 400px; flex-direction: column; svg { width: 50px; height: 50px; fill: ${props => props.theme.eCashBlue}; } `; export const SidePaddingCtn = styled.div` padding: 0px 30px; @media (max-width: 768px) { padding: 0px 15px; } `; export const FormLabel = styled.label` font-size: 16px; margin-bottom: 5px; text-align: left; width: 100%; display: inline-block; color: ${props => props.theme.contrast}; `; export const WalletInfoCtn = styled.div` background: ${props => props.theme.walletInfoContainer}; width: 100%; padding: 40px 20px; `; export const BalanceHeaderFiatWrap = styled.div` color: ${props => props.theme.contrast}; width: 100%; font-size: 16px; @media (max-width: 768px) { font-size: 16px; } `; export const BalanceHeaderWrap = styled.div` color: ${props => props.theme.contrast}; width: 100%; font-size: 28px; margin-bottom: 0px; font-weight: bold; line-height: 1.4em; @media (max-width: 768px) { font-size: 24px; } `; export const ZeroBalanceHeader = styled.div` color: ${props => props.theme.contrast}; width: 100%; font-size: 14px; margin-bottom: 5px; `; export const TokenParamLabel = styled.span` font-weight: bold; `; export const AlertMsg = styled.p` color: ${props => props.theme.forms.error} !important; `; export const ConvertAmount = styled.div` color: ${props => props.theme.contrast}; width: 100%; font-size: 14px; margin-bottom: 10px; @media (max-width: 768px) { font-size: 12px; } `; diff --git a/web/cashtab/src/components/Common/Notifications.js b/web/cashtab/src/components/Common/Notifications.js index 81332534e..1ec0596a5 100644 --- a/web/cashtab/src/components/Common/Notifications.js +++ b/web/cashtab/src/components/Common/Notifications.js @@ -1,192 +1,210 @@ import * as React from 'react'; import { notification } from 'antd'; import { CashReceivedNotificationIcon, TokenReceivedNotificationIcon, } from '@components/Common/CustomIcons'; import Paragraph from 'antd/lib/typography/Paragraph'; import { currency } from '@components/Common/Ticker'; import { MessageSignedNotificationIcon } from '@components/Common/CustomIcons'; import { isMobile } from 'react-device-detect'; const getDeviceNotificationStyle = () => { if (isMobile) { const notificationStyle = { width: '100%', marginTop: '10%', }; return notificationStyle; } if (!isMobile) { const notificationStyle = { width: '100%', }; return notificationStyle; } }; // Success Notifications: const sendXecNotification = link => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: 'Success', description: ( Transaction successful. Click to view in block explorer. ), duration: currency.notificationDurationShort, icon: , style: notificationStyle, }); }; const createTokenNotification = link => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: 'Success', description: ( Token created! Click to view in block explorer. ), icon: , style: notificationStyle, }); }; const tokenIconSubmitSuccess = () => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: 'Success', description: ( Your eToken icon was successfully submitted. ), icon: , style: notificationStyle, }); }; const sendTokenNotification = link => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: 'Success', description: ( Transaction successful. Click to view in block explorer. ), duration: currency.notificationDurationShort, icon: , style: notificationStyle, }); }; +const burnTokenNotification = link => { + const notificationStyle = getDeviceNotificationStyle(); + notification.success({ + message: 'Success', + description: ( + + + eToken burn successful. Click to view in block explorer. + + + ), + duration: currency.notificationDurationLong, + icon: , + style: notificationStyle, + }); +}; + const xecReceivedNotification = ( balances, previousBalances, cashtabSettings, fiatPrice, ) => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: 'Transaction received', description: ( +{' '} {parseFloat( Number( balances.totalBalance - previousBalances.totalBalance, ).toFixed(currency.cashDecimals), ).toLocaleString()}{' '} {currency.ticker}{' '} {cashtabSettings && cashtabSettings.fiatCurrency && `(${ currency.fiatCurrencies[cashtabSettings.fiatCurrency] .symbol }${( Number( balances.totalBalance - previousBalances.totalBalance, ) * fiatPrice ).toFixed( currency.cashDecimals, )} ${cashtabSettings.fiatCurrency.toUpperCase()})`} ), duration: currency.notificationDurationShort, icon: , style: notificationStyle, }); }; const eTokenReceivedNotification = ( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ) => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: `${currency.tokenTicker} transaction received: ${receivedSlpTicker}`, description: ( You received {receivedSlpQty.toString()} {receivedSlpName} ), duration: currency.notificationDurationShort, icon: , style: notificationStyle, }); }; // Error Notification: const errorNotification = (error, message, stringDescribingCallEvent) => { const notificationStyle = getDeviceNotificationStyle(); console.log(error, message, stringDescribingCallEvent); notification.error({ message: 'Error', description: message, duration: currency.notificationDurationLong, style: notificationStyle, }); }; const messageSignedNotification = msgSignature => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: 'Message Signature Generated', description: {msgSignature}, icon: , style: notificationStyle, }); }; const generalNotification = (data, msgStr) => { const notificationStyle = getDeviceNotificationStyle(); notification.success({ message: msgStr, description: data, style: notificationStyle, }); }; export { sendXecNotification, createTokenNotification, tokenIconSubmitSuccess, sendTokenNotification, xecReceivedNotification, eTokenReceivedNotification, errorNotification, messageSignedNotification, generalNotification, + burnTokenNotification, }; diff --git a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap index c9c26b8de..393677074 100644 --- a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap +++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap @@ -1,915 +1,915 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Configure with a wallet 1`] = `

Backup your wallet

Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
Click to reveal seed phrase

Manage Wallets

Fiat Currency

US Dollar ($)

General Settings

Lock App
Send Confirmations
`; exports[`Configure without a wallet 1`] = `

Backup your wallet

Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.

Manage Wallets

Fiat Currency

US Dollar ($)

General Settings

Lock App
Send Confirmations
`; diff --git a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap index 4e9e690a6..e36e80670 100644 --- a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap +++ b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap @@ -1,449 +1,449 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [

MigrationTestAlpha

0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [

MigrationTestAlpha

0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

MigrationTestAlpha

0.06 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [

MigrationTestAlpha

0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Without wallet defined 1`] = `

Welcome to Cashtab!

Cashtab is an open source, non-custodial web wallet for eCash .

Want to learn more? Check out the Cashtab documentation.

`; diff --git a/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap b/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap index 551e7045a..f942f0ce3 100644 --- a/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap +++ b/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap @@ -1,534 +1,534 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Wallet with BCH balances and tokens 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Wallet with BCH balances and tokens and state field 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Wallet without BCH balance 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Without wallet defined 1`] = `

Welcome to Cashtab!

Cashtab is an open source, non-custodial web wallet for eCash .

Want to learn more? Check out the Cashtab documentation.

`; diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index 4748434bc..96166cff4 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,499 +1,686 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { Form, message, Row, Col, Alert, Descriptions, - Popover, Modal, + Button, + Input, } from 'antd'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; +import { FireTwoTone } from '@ant-design/icons'; import { DestinationAmount, DestinationAddressSingle, + AntdFormWrapper, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; import { SidePaddingCtn } from '@components/Common/Atoms'; import BalanceHeader from '@components/Common/BalanceHeader'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { Img } from 'react-image'; import makeBlockie from 'ethereum-blockies-base64'; import BigNumber from 'bignumber.js'; import { currency, parseAddressForParams } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { getWalletState, toLegacyToken } from '@utils/cashMethods'; import ApiError from '@components/Common/ApiError'; import { sendTokenNotification, errorNotification, + burnTokenNotification, } from '@components/Common/Notifications'; -import { isValidXecAddress, isValidEtokenAddress } from '@utils/validation'; +import { + isValidXecAddress, + isValidEtokenAddress, + isValidEtokenBurnAmount, +} from '@utils/validation'; import { formatDate } from '@utils/formatting'; import styled, { css } from 'styled-components'; import TokenIcon from '@components/Tokens/TokenIcon'; const AntdDescriptionsCss = css` .ant-descriptions-item-label, + .ant-input-number, .ant-descriptions-item-content { background-color: ${props => props.theme.contrast} !important; color: ${props => props.theme.dropdownText}; } `; const AntdDescriptionsWrapper = styled.div` ${AntdDescriptionsCss} `; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { const { wallet, apiError, cashtabSettings } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens, slpBalancesAndUtxos } = walletState; const token = tokens.find(token => token.tokenId === tokenId); const [tokenStats, setTokenStats] = useState(null); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = useState(false); + const [eTokenBurnAmount, setETokenBurnAmount] = useState(new BigNumber(1)); + const [showConfirmBurnEtoken, setShowConfirmBurnEtoken] = useState(false); + const [burnTokenAmountError, setBurnTokenAmountError] = useState(false); + const [burnConfirmationValid, setBurnConfirmationValid] = useState(null); + const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] = + useState(''); + // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); const [isModalVisible, setIsModalVisible] = useState(false); const [formData, setFormData] = useState({ value: '', address: '', }); - const { getBCH, getRestUrl, sendToken, getTokenStats } = useBCH(); + const { getBCH, getRestUrl, sendToken, getTokenStats, burnEtoken } = + useBCH(); // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const BCH = jestBCH ? jestBCH : getBCH(); // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(BCH, tokenId).then( result => { setTokenStats(result); }, err => { console.log(`Error getting token stats: ${err}`); }, ); } // Clears address and amount fields following sendTokenNotification const clearInputForms = () => { setFormData({ value: '', address: '', }); }; async function submit() { setFormData({ ...formData, }); if ( !formData.address || !formData.value || Number(formData.value <= 0) || sendTokenAmountError ) { return; } // Event("Category", "Action", "Label") // Track number of SLPA send transactions and // SLPA token IDs Event('SendToken.js', 'Send', tokenId); passLoadingStatus(true); const { address, value } = formData; // Clear params from address let cleanAddress = address.split('?')[0]; // Convert to simpleledger prefix if etoken cleanAddress = toLegacyToken(cleanAddress); try { const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); sendTokenNotification(link); clearInputForms(); } catch (e) { passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Sending eToken'); } } const handleSlpAmountChange = e => { let error = false; const { value, name } = e.target; // test if exceeds balance using BigNumber let isGreaterThanBalance = false; if (!isNaN(value)) { const bigValue = new BigNumber(value); // Returns 1 if greater, -1 if less, 0 if the same, null if n/a isGreaterThanBalance = bigValue.comparedTo(token.balance); } // Validate value for > 0 if (isNaN(value)) { error = 'Amount must be a number'; } else if (value <= 0) { error = 'Amount must be greater than 0'; } else if (token && token.balance && isGreaterThanBalance === 1) { error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; } else if (!isNaN(value) && value.toString().includes('.')) { if (value.toString().split('.')[1].length > token.info.decimals) { error = `This token only supports ${token.info.decimals} decimal places`; } } setSendTokenAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const handleTokenAddressChange = e => { const { value, name } = e.target; // validate for token address // validate for parameters // show warning that query strings are not supported let error = false; let addressString = value; const isValid = isValidEtokenAddress(addressString); const addressInfo = parseAddressForParams(addressString); /* Model - addressInfo = { address: '', queryString: '', amount: null, }; */ const { address, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = 'Address is not a valid etoken: address'; // If valid address but xec format if (isValidXecAddress(address)) { error = `Cashtab does not support sending eTokens to XEC addresses. Please convert to an eToken address.`; } } setSendTokenAddressError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear this error before updating field setSendTokenAmountError(false); try { let value = token.balance; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; const checkForConfirmationBeforeSendEtoken = () => { if (cashtabSettings.sendModal) { setIsModalVisible(cashtabSettings.sendModal); } else { // if the user does not have the send confirmation enabled in settings then send directly submit(); } }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; + const handleEtokenBurnAmountChange = e => { + const { value } = e.target; + const burnAmount = new BigNumber(value); + setETokenBurnAmount(burnAmount); + + let error = false; + if (!isValidEtokenBurnAmount(burnAmount, token.balance)) { + error = 'Burn amount must be between 1 and ' + token.balance; + } + + setBurnTokenAmountError(error); + }; + + const onMaxBurn = () => { + setETokenBurnAmount(token.balance); + + // trigger validation on the inserted max value + handleEtokenBurnAmountChange({ + target: { + value: token.balance, + }, + }); + }; + + async function burn() { + if ( + !burnConfirmationValid || + burnConfirmationValid === null || + !eTokenBurnAmount + ) { + return; + } + + // Event("Category", "Action", "Label") + Event('SendToken.js', 'Burn eToken', tokenId); + + passLoadingStatus(true); + + try { + const link = await burnEtoken(BCH, wallet, slpBalancesAndUtxos, { + tokenId: tokenId, + amount: eTokenBurnAmount, + }); + burnTokenNotification(link); + clearInputForms(); + setShowConfirmBurnEtoken(false); + passLoadingStatus(false); + setConfirmationOfEtokenToBeBurnt(''); + } catch (e) { + setShowConfirmBurnEtoken(false); + passLoadingStatus(false); + setConfirmationOfEtokenToBeBurnt(''); + let message; + + if (!e.error && !e.message) { + message = `Transaction failed: no response from ${getRestUrl()}.`; + } else if (/dust/.test(e.error)) { + message = 'Unable to burn due to insufficient eToken utxos.'; + } else if ( + /Could not communicate with full node or other external service/.test( + e.error, + ) + ) { + message = 'Could not communicate with API. Please try again.'; + } else { + message = e.message || e.error || JSON.stringify(e); + } + errorNotification(e, message, 'Burning eToken'); + } + } + + const handleBurnConfirmationInput = e => { + const { value } = e.target; + + if (value && value === `burn ${token.info.tokenTicker}`) { + setBurnConfirmationValid(true); + } else { + setBurnConfirmationValid(false); + } + setConfirmationOfEtokenToBeBurnt(value); + }; + + const handleBurnAmountInput = () => { + if (!burnTokenAmountError) { + setShowConfirmBurnEtoken(true); + } + }; + useEffect(() => { // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked passLoadingStatus(false); }, [token]); return ( <>

{token && token.info && formData ? `Are you sure you want to send ${formData.value}${' '} ${token.info.tokenTicker} to ${formData.address}?` : ''}

{!token && } {token && ( + {/* eToken burn modal */} + setShowConfirmBurnEtoken(false)} + > + +
+ + } + placeholder={`Type "burn ${token.info.tokenTicker}" to confirm`} + name="etokenToBeBurnt" + value={confirmationOfEtokenToBeBurnt} + onChange={e => + handleBurnConfirmationInput(e) + } + /> + +
+
+
handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.tokenTicker} Address`, name: 'address', onChange: e => handleTokenAddressChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} />
{apiError || sendTokenAmountError || sendTokenAddressError ? ( <> Send {token.info.tokenName} ) : ( checkForConfirmationBeforeSendEtoken() } > Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && } {tokenStats !== null && ( {token.info.decimals} {token.tokenId} {tokenStats && ( <> {tokenStats.documentUri} {tokenStats.timestampUnix !== null ? formatDate( tokenStats.timestampUnix, navigator.language, ) : 'Just now (Genesis tx confirming)'} {tokenStats.containsBaton ? 'No' : 'Yes'} {tokenStats.initialTokenQty.toLocaleString()} {tokenStats.totalBurned.toLocaleString()} {tokenStats.totalMinted.toLocaleString()} {tokenStats.circulatingSupply.toLocaleString()} + + + handleEtokenBurnAmountChange( + e, + ), + initialvalue: 1, + value: eTokenBurnAmount, + prefix: ( + + ), + }} + /> + + )} )}
)} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in SendToken.test.js - status => {console.log(status)} is an arbitrary stub function */ SendToken.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; SendToken.propTypes = { tokenId: PropTypes.string, jestBCH: PropTypes.object, passLoadingStatus: PropTypes.func, }; export default SendToken; diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap index de71778fd..cb41208e1 100644 --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap @@ -1,2351 +1,2351 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [

MigrationTestAlpha

You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [

MigrationTestAlpha

You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

MigrationTestAlpha

0.06 XEC
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [

MigrationTestAlpha

You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
, ] `; exports[`Without wallet defined 1`] = ` Array [
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
, ] `; diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap index b149f9337..25d85fd9a 100644 --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap @@ -1,244 +1,244 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens 1`] = `null`; exports[`Wallet with BCH balances and tokens and state field 1`] = `
6.001 TBS
TBS max
`; exports[`Without wallet defined 1`] = `null`; diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap index 1bfa85cc4..8f670123f 100644 --- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap +++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap @@ -1,375 +1,375 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens and state field 1`] = `

Create a Token

Click, or drag file to this area to upload

Only jpg or png accepted

`; diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap index 59f8b09bd..105c58345 100644 --- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap +++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap @@ -1,580 +1,580 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [

MigrationTestAlpha

0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [

MigrationTestAlpha

0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

MigrationTestAlpha

0.06 XEC
,

Create a Token

Click, or drag file to this area to upload

Only jpg or png accepted

, ] `; exports[`Wallet without BCH balance 1`] = ` Array [

MigrationTestAlpha

0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, ] `; exports[`Without wallet defined 1`] = ` Array [
0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, ] `; diff --git a/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js b/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js index 0e6601807..d869d1685 100644 --- a/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js +++ b/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js @@ -1,115 +1,133 @@ // Expected result of applying parseTxData to mockTxDataWityhPassthrough[0] export const mockSentCashTx = [ { amountReceived: 0, amountSent: 0.000042, blocktime: 1614380741, confirmations: 2721, destinationAddress: 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', height: 674993, outgoingTx: true, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', replyAddress: null, tokenTx: false, decryptionSuccess: false, txid: '089f2188d5771a7de0589def2b8d6c1a1f33f45b6de26d9a0ef32782f019ecf1', }, ]; export const mockReceivedCashTx = [ { amountReceived: 3, amountSent: 0, blocktime: 1612567121, confirmations: 5637, destinationAddress: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', height: 672077, outgoingTx: false, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', replyAddress: null, tokenTx: false, decryptionSuccess: false, txid: '42d39fbe068a40fe691f987b22fdf04b80f94d71d2fec20a58125e7b1a06d2a9', }, ]; export const mockSentTokenTx = [ { amountReceived: 0, amountSent: 0.00000546, blocktime: 1614027278, confirmations: 3270, destinationAddress: 'bitcoincash:qzj5zu6fgg8v2we82gh76xnrk9njcregluzgaztm45', height: 674444, outgoingTx: true, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', replyAddress: null, tokenTx: true, decryptionSuccess: false, txid: 'ffe3a7500dbcc98021ad581c98d9947054d1950a7f3416664715066d3d20ad72', }, ]; export const mockReceivedTokenTx = [ { amountReceived: 0.00000546, amountSent: 0, blocktime: 1613859311, confirmations: 3571, destinationAddress: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', height: 674143, outgoingTx: false, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', replyAddress: null, tokenTx: true, decryptionSuccess: false, txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', }, ]; export const mockSentOpReturnMessageTx = [ { amountReceived: 0, amountSent: 0, blocktime: 1635507345, confirmations: 59, destinationAddress: undefined, height: undefined, opReturnMessage: 'bingoelectrum', replyAddress: null, outgoingTx: false, tokenTx: false, isCashtabMessage: false, isEncryptedMessage: false, decryptionSuccess: false, txid: 'dd35690b0cefd24dcc08acba8694ecd49293f365a81372cb66c8f1c1291d97c5', }, ]; export const mockReceivedOpReturnMessageTx = [ { amountReceived: 0, amountSent: 0, blocktime: 1635511136, confirmations: 70, destinationAddress: undefined, height: undefined, opReturnMessage: 'cashtabular', replyAddress: 'ecash:qrxkkzsmrxcjmz8x90fx2uztt83cuu0u254w09pq5p', outgoingTx: false, tokenTx: false, isCashtabMessage: true, isEncryptedMessage: false, decryptionSuccess: false, txid: '5adc33b5c0509b31c6da359177b19467c443bdc4dd37c283c0f87244c0ad63af', }, ]; +export const mockBurnEtokenTx = [ + { + amountReceived: 0, + amountSent: 0, + blocktime: 1643286535, + confirmations: 3, + destinationAddress: undefined, + decryptionSuccess: false, + height: undefined, + isCashtabMessage: false, + isEncryptedMessage: false, + opReturnMessage: '', + outgoingTx: false, + replyAddress: null, + tokenTx: true, + txid: '8b569d64a7e51d1d3cf1cf2b99d8b34451bbebc7df6b67232e5b770418b0428c', + }, +]; diff --git a/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js b/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js index 2c05fec6f..2f07d206a 100644 --- a/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js +++ b/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js @@ -1,872 +1,942 @@ export default [ { txid: '089f2188d5771a7de0589def2b8d6c1a1f33f45b6de26d9a0ef32782f019ecf1', hash: '089f2188d5771a7de0589def2b8d6c1a1f33f45b6de26d9a0ef32782f019ecf1', version: 2, size: 225, locktime: 0, vin: [ { txid: 'b96da810b15deb312ad4508a165033ca8ffa282f88e5b7b0e79be09a0b0424f9', vout: 1, scriptSig: { asm: '3044022064084d72b1bb7ca148d1950cf07494ffb397cb3df53b72afa8bd844b80369ecd02203ae21f14ba5019f38bc0b80b99e7c8cc1d5d3360ca7bab56be28ef583fe5c6a6[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '473044022064084d72b1bb7ca148d1950cf07494ffb397cb3df53b72afa8bd844b80369ecd02203ae21f14ba5019f38bc0b80b99e7c8cc1d5d3360ca7bab56be28ef583fe5c6a6412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, ], vout: [ { value: 0.000042, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, { value: 0.6244967, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, ], hex: '0200000001f924040b9ae09be7b0b7e5882f28fa8fca3350168a50d42a31eb5db110a86db9010000006a473044022064084d72b1bb7ca148d1950cf07494ffb397cb3df53b72afa8bd844b80369ecd02203ae21f14ba5019f38bc0b80b99e7c8cc1d5d3360ca7bab56be28ef583fe5c6a6412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffff0268100000000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac06e8b803000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00000000', blockhash: '000000000000000087dd4ca6308e835edfba871fee36d3e53ad3c9545c4b1719', confirmations: 2721, time: 1614380741, blocktime: 1614380741, height: 674993, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'ffe3a7500dbcc98021ad581c98d9947054d1950a7f3416664715066d3d20ad72', hash: 'ffe3a7500dbcc98021ad581c98d9947054d1950a7f3416664715066d3d20ad72', version: 2, size: 480, locktime: 0, vin: [ { txid: 'b980b35b794ad73d8aae312385e82d9be8086e7b743e1c6a468db8db8ac74bd8', vout: 3, scriptSig: { asm: '30440220538de8f61d716c899e6a2cd78ca46162edaaa5f0d000ebbbc875608e5639170a02206a7fc8f7c16cef1c56667a8da6d5e480f440ecf43238879ad9f8785a0473a72b[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '4730440220538de8f61d716c899e6a2cd78ca46162edaaa5f0d000ebbbc875608e5639170a02206a7fc8f7c16cef1c56667a8da6d5e480f440ecf43238879ad9f8785a0473a72b412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, { txid: 'b980b35b794ad73d8aae312385e82d9be8086e7b743e1c6a468db8db8ac74bd8', vout: 2, scriptSig: { asm: '3045022100ce03e19bd181b903adc6f192d4ad0900e6816f6e62282cefff05c22cf36a647602202b296a2ed1805f0b0a9aa5f99158685298e7a0aff406fedb8abb8e0afaf48ca4[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '483045022100ce03e19bd181b903adc6f192d4ad0900e6816f6e62282cefff05c22cf36a647602202b296a2ed1805f0b0a9aa5f99158685298e7a0aff406fedb8abb8e0afaf48ca4412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, ], vout: [ { value: 0, n: 0, scriptPubKey: { asm: 'OP_RETURN 5262419 1 1145980243 50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e 0000000000000003 000000000000005e', hex: '6a04534c500001010453454e442050d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e08000000000000000308000000000000005e', type: 'nulldata', }, }, { value: 0.00000546, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 a5417349420ec53b27522fed1a63b1672c0f28ff OP_EQUALVERIFY OP_CHECKSIG', hex: '76a914a5417349420ec53b27522fed1a63b1672c0f28ff88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qzj5zu6fgg8v2we82gh76xnrk9njcregluzgaztm45', ], }, }, { value: 0.00000546, n: 2, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, { value: 4.99996074, n: 3, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, ], hex: '0200000002d84bc78adbb88d466a1c3e747b6e08e89b2de8852331ae8a3dd74a795bb380b9030000006a4730440220538de8f61d716c899e6a2cd78ca46162edaaa5f0d000ebbbc875608e5639170a02206a7fc8f7c16cef1c56667a8da6d5e480f440ecf43238879ad9f8785a0473a72b412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffffd84bc78adbb88d466a1c3e747b6e08e89b2de8852331ae8a3dd74a795bb380b9020000006b483045022100ce03e19bd181b903adc6f192d4ad0900e6816f6e62282cefff05c22cf36a647602202b296a2ed1805f0b0a9aa5f99158685298e7a0aff406fedb8abb8e0afaf48ca4412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffff040000000000000000406a04534c500001010453454e442050d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e08000000000000000308000000000000005e22020000000000001976a914a5417349420ec53b27522fed1a63b1672c0f28ff88ac22020000000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688acaa55cd1d000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00000000', blockhash: '00000000000000007053867de29516374a23d7adfb08ccb47cfbea0e98a49e5b', confirmations: 3270, time: 1614027278, blocktime: 1614027278, height: 674444, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'b980b35b794ad73d8aae312385e82d9be8086e7b743e1c6a468db8db8ac74bd8', hash: 'b980b35b794ad73d8aae312385e82d9be8086e7b743e1c6a468db8db8ac74bd8', version: 2, size: 479, locktime: 0, vin: [ { txid: 'ec9c20c2c5cd5aa4c9261a9f97e68734b175962c4b3d9edc996dd415dd03c2e7', vout: 0, scriptSig: { asm: '3044022075cb93e60ffb792b2715d96f3d31033e8f385bb9bfeadf99f7b1055d749a33cc022028292ee8ffaed64cbc6f9b680db36e031250672a6b0c5cfd23f9a61977d52ed7[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '473044022075cb93e60ffb792b2715d96f3d31033e8f385bb9bfeadf99f7b1055d749a33cc022028292ee8ffaed64cbc6f9b680db36e031250672a6b0c5cfd23f9a61977d52ed7412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', vout: 1, scriptSig: { asm: '304402203ea0558cd917eb8f6c286e79ffcc5dd1f5accb66c2e5836628d6be6f9d03ca260220120a6da92b6f44bdfcd3ef7b08263d3f73d99ff4b1f83b8f998ff1355f3f0d2e[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '47304402203ea0558cd917eb8f6c286e79ffcc5dd1f5accb66c2e5836628d6be6f9d03ca260220120a6da92b6f44bdfcd3ef7b08263d3f73d99ff4b1f83b8f998ff1355f3f0d2e412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, ], vout: [ { value: 0, n: 0, scriptPubKey: { asm: 'OP_RETURN 5262419 1 1145980243 50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e 0000000000000003 0000000000000061', hex: '6a04534c500001010453454e442050d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e080000000000000003080000000000000061', type: 'nulldata', }, }, { value: 0.00000546, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 d4fa9121bcd065dd93e58831569cf51ef5a74f61 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a914d4fa9121bcd065dd93e58831569cf51ef5a74f6188ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qr204yfphngxthvnukyrz45u7500tf60vyea48xwmd', ], }, }, { value: 0.00000546, n: 2, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, { value: 4.99998974, n: 3, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, ], hex: '0200000002e7c203dd15d46d99dc9e3d4b2c9675b13487e6979f1a26c9a45acdc5c2209cec000000006a473044022075cb93e60ffb792b2715d96f3d31033e8f385bb9bfeadf99f7b1055d749a33cc022028292ee8ffaed64cbc6f9b680db36e031250672a6b0c5cfd23f9a61977d52ed7412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffffe6c0ab0db2ba53f1aee96ecd11836fb72bd75d865c51c6345afac5c0d80d8d61010000006a47304402203ea0558cd917eb8f6c286e79ffcc5dd1f5accb66c2e5836628d6be6f9d03ca260220120a6da92b6f44bdfcd3ef7b08263d3f73d99ff4b1f83b8f998ff1355f3f0d2e412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffff040000000000000000406a04534c500001010453454e442050d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e08000000000000000308000000000000006122020000000000001976a914d4fa9121bcd065dd93e58831569cf51ef5a74f6188ac22020000000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688acfe60cd1d000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00000000', blockhash: '0000000000000000a9f812d56e2249b7c462ce499a0852bdfe20bb46c1bb9f92', confirmations: 3278, time: 1614021424, blocktime: 1614021424, height: 674436, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', hash: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', version: 2, size: 436, locktime: 0, vin: [ { txid: '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', vout: 3, scriptSig: { asm: '30440220664f988b86035ddcdff6e9c3b8e140712eca297750d056e41577a0bf0059e7ff022030982b3fcab1cab5d6086bc935e941e7d22efbb0ad5ccca0268515c5c8306089[ALL|FORKID] 034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3', hex: '4730440220664f988b86035ddcdff6e9c3b8e140712eca297750d056e41577a0bf0059e7ff022030982b3fcab1cab5d6086bc935e941e7d22efbb0ad5ccca0268515c5c83060894121034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3', }, sequence: 4294967295, }, { txid: '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e', vout: 1, scriptSig: { asm: '304402203ce88e0a95d5581ad567c0468c87a08027aa5ecdecd614a168d833d7ecc02c1c022013ddd81147b44ad5488107d5c4d535f7f59e9fa46840451d39422aace284b2b7[ALL|FORKID] 034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3', hex: '47304402203ce88e0a95d5581ad567c0468c87a08027aa5ecdecd614a168d833d7ecc02c1c022013ddd81147b44ad5488107d5c4d535f7f59e9fa46840451d39422aace284b2b74121034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3', }, sequence: 4294967295, }, ], vout: [ { value: 0, n: 0, scriptPubKey: { asm: 'OP_RETURN 5262419 1 1145980243 50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e 0000000000000064', hex: '6a04534c500001010453454e442050d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e080000000000000064', type: 'nulldata', }, }, { value: 0.00000546, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, { value: 0.00088064, n: 2, scriptPubKey: { asm: 'OP_DUP OP_HASH160 b8d9512d2adf8b4e70c45c26b6b00d75c28eaa96 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qzudj5fd9t0cknnsc3wzdd4sp46u9r42jcnqnwfss0', ], }, }, ], hex: '02000000020e41711243954c8ffe409761e994272af43ced6f56c8c6afa7cd55622c29d850030000006a4730440220664f988b86035ddcdff6e9c3b8e140712eca297750d056e41577a0bf0059e7ff022030982b3fcab1cab5d6086bc935e941e7d22efbb0ad5ccca0268515c5c83060894121034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3ffffffff0e41711243954c8ffe409761e994272af43ced6f56c8c6afa7cd55622c29d850010000006a47304402203ce88e0a95d5581ad567c0468c87a08027aa5ecdecd614a168d833d7ecc02c1c022013ddd81147b44ad5488107d5c4d535f7f59e9fa46840451d39422aace284b2b74121034509251caa5f01e2787c436949eb94d71dcc451bcde5791ae5b7109255f5f0a3ffffffff030000000000000000376a04534c500001010453454e442050d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e08000000000000006422020000000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00580100000000001976a914b8d9512d2adf8b4e70c45c26b6b00d75c28eaa9688ac00000000', blockhash: '000000000000000034c77993a35c74fe2dddace27198681ca1e89e928d0c2fff', confirmations: 3571, time: 1613859311, blocktime: 1613859311, height: 674143, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'f90631b48521a4147dd9dd7091ce936eddc0c3e6221ec87fa4fabacc453a0b95', hash: 'f90631b48521a4147dd9dd7091ce936eddc0c3e6221ec87fa4fabacc453a0b95', version: 2, size: 437, locktime: 0, vin: [ { txid: 'db464f77ac97deabc28df07a7e4a2e261c854a8ec4dc959b89b10531966f6cbf', vout: 0, scriptSig: { asm: '3044022065622a7aa065f56abe84f3589c983a768e3ef5d72c9352991d6b584a2a16dcb802200c1c0065106207715a024624ed951e851d4f742c55a704e9531bebd2ef84fc14[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '473044022065622a7aa065f56abe84f3589c983a768e3ef5d72c9352991d6b584a2a16dcb802200c1c0065106207715a024624ed951e851d4f742c55a704e9531bebd2ef84fc1441210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: 'acbb66f826211f40b89e84d9bd2143dfb541d67e1e3c664b17ccd3ba66327a9e', vout: 1, scriptSig: { asm: '3045022100b475cf7d1eaf37641d2107f13be0ef9acbd17b252ed3f9ae349edfdcd6a97cf402202bf2852dfa905e6d50c96a622d2838408ceb979245a4342d5096acc938135804[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '483045022100b475cf7d1eaf37641d2107f13be0ef9acbd17b252ed3f9ae349edfdcd6a97cf402202bf2852dfa905e6d50c96a622d2838408ceb979245a4342d5096acc93813580441210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, ], vout: [ { value: 0, n: 0, scriptPubKey: { asm: 'OP_RETURN 5262419 1 1145980243 bfddfcfc9fb9a8d61ed74fa94b5e32ccc03305797eea461658303df5805578ef 0000000000000001', hex: '6a04534c500001010453454e4420bfddfcfc9fb9a8d61ed74fa94b5e32ccc03305797eea461658303df5805578ef080000000000000001', type: 'nulldata', }, }, { value: 0.00000546, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, { value: 9.99997101, n: 2, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, ], hex: '0200000002bf6c6f963105b1899b95dcc48e4a851c262e4a7e7af08dc2abde97ac774f46db000000006a473044022065622a7aa065f56abe84f3589c983a768e3ef5d72c9352991d6b584a2a16dcb802200c1c0065106207715a024624ed951e851d4f742c55a704e9531bebd2ef84fc1441210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffff9e7a3266bad3cc174b663c1e7ed641b5df4321bdd9849eb8401f2126f866bbac010000006b483045022100b475cf7d1eaf37641d2107f13be0ef9acbd17b252ed3f9ae349edfdcd6a97cf402202bf2852dfa905e6d50c96a622d2838408ceb979245a4342d5096acc93813580441210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffff030000000000000000376a04534c500001010453454e4420bfddfcfc9fb9a8d61ed74fa94b5e32ccc03305797eea461658303df5805578ef08000000000000000122020000000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688acadbe9a3b000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac00000000', blockhash: '0000000000000000132378b84a7477b7d601faedec302264bde1e89b1480e364', confirmations: 5013, time: 1612966022, blocktime: 1612966022, height: 672701, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: '42d39fbe068a40fe691f987b22fdf04b80f94d71d2fec20a58125e7b1a06d2a9', hash: '42d39fbe068a40fe691f987b22fdf04b80f94d71d2fec20a58125e7b1a06d2a9', version: 2, size: 226, locktime: 0, vin: [ { txid: '5e0436c6741e226d05c5b7e7e23de8213d3583e2669e50a80b908bf4cb471317', vout: 1, scriptSig: { asm: '3045022100f8a8eca8f5d6149511c518d41015512f8164a5be6f01e9efd609db9a429f4872022059121e122043b43eae77b5e132b8f798a290e6eed8a2026a0656540cd1bd752b[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '483045022100f8a8eca8f5d6149511c518d41015512f8164a5be6f01e9efd609db9a429f4872022059121e122043b43eae77b5e132b8f798a290e6eed8a2026a0656540cd1bd752b41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, ], vout: [ { value: 3, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, { value: 6.9999586, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, ], hex: '0200000001171347cbf48b900ba8509e66e283353d21e83de2e7b7c5056d221e74c636045e010000006b483045022100f8a8eca8f5d6149511c518d41015512f8164a5be6f01e9efd609db9a429f4872022059121e122043b43eae77b5e132b8f798a290e6eed8a2026a0656540cd1bd752b41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffff0200a3e111000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688acd416b929000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac00000000', blockhash: '00000000000000008f563edf8604e537fe0d1e80f1c7c2d97dd094824f804ba3', confirmations: 5637, time: 1612567121, blocktime: 1612567121, height: 672077, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'b96da810b15deb312ad4508a165033ca8ffa282f88e5b7b0e79be09a0b0424f9', hash: 'b96da810b15deb312ad4508a165033ca8ffa282f88e5b7b0e79be09a0b0424f9', version: 2, size: 226, locktime: 0, vin: [ { txid: '9ad75af97f0617a3729c2bd31bf7c4b380230e661cc921a3c6be0febc75a3e49', vout: 1, scriptSig: { asm: '3045022100d59e6fad4d1d57796f229a7d4aa3b01fc3241132dae9bc406c66fa33d7aef21c022036a5f432d6d99f65848ac12c00bde2b5ba7e63a9f9a74349d9ab8ec39db26f8e[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '483045022100d59e6fad4d1d57796f229a7d4aa3b01fc3241132dae9bc406c66fa33d7aef21c022036a5f432d6d99f65848ac12c00bde2b5ba7e63a9f9a74349d9ab8ec39db26f8e412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, ], vout: [ { value: 0.12345, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, { value: 0.62455003, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, ], hex: '0200000001493e5ac7eb0fbec6a321c91c660e2380b3c4f71bd32b9c72a317067ff95ad79a010000006b483045022100d59e6fad4d1d57796f229a7d4aa3b01fc3241132dae9bc406c66fa33d7aef21c022036a5f432d6d99f65848ac12c00bde2b5ba7e63a9f9a74349d9ab8ec39db26f8e412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffff02a85ebc00000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88acdbfcb803000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00000000', blockhash: '00000000000000008f563edf8604e537fe0d1e80f1c7c2d97dd094824f804ba3', confirmations: 5637, time: 1612567121, blocktime: 1612567121, height: 672077, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'db464f77ac97deabc28df07a7e4a2e261c854a8ec4dc959b89b10531966f6cbf', hash: 'db464f77ac97deabc28df07a7e4a2e261c854a8ec4dc959b89b10531966f6cbf', version: 2, size: 225, locktime: 0, vin: [ { txid: '1452267e57429edcfdcb1184b24becea6ddf8f8a4f8e130dad6248545d9f8e75', vout: 1, scriptSig: { asm: '30440220184921bfce634a57b5220f06b11b64c0cb7e67ecd9c634335e3e933e35a7a969022038b2074e1d75aa4f6945d150bae5b8a1d426f4284da2b96336fa0fc741eb6de7[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '4730440220184921bfce634a57b5220f06b11b64c0cb7e67ecd9c634335e3e933e35a7a969022038b2074e1d75aa4f6945d150bae5b8a1d426f4284da2b96336fa0fc741eb6de7412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, ], vout: [ { value: 10.00000001, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, { value: 2.8830607, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, ], hex: '0200000001758e9f5d544862ad0d138e4f8a8fdf6deaec4bb28411cbfddc9e42577e265214010000006a4730440220184921bfce634a57b5220f06b11b64c0cb7e67ecd9c634335e3e933e35a7a969022038b2074e1d75aa4f6945d150bae5b8a1d426f4284da2b96336fa0fc741eb6de7412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffff0201ca9a3b000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac96332f11000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00000000', blockhash: '00000000000000008f563edf8604e537fe0d1e80f1c7c2d97dd094824f804ba3', confirmations: 5637, time: 1612567121, blocktime: 1612567121, height: 672077, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'e32c20137e590f253b8d198608f7fffd428fc0bd7a9a0675bb6af091d1cb2ea4', hash: 'e32c20137e590f253b8d198608f7fffd428fc0bd7a9a0675bb6af091d1cb2ea4', version: 2, size: 373, locktime: 0, vin: [ { txid: 'f63e890423b3bffa6e01be2dcb4942940c2e8a1985926411558a22d1b5dd0e29', vout: 1, scriptSig: { asm: '3045022100c7f51ff0888c182a1a60c08904d8116c9d2e31cb7d2fd5b63c2bf9fd7b246fc102202ee786d2052448621c4a04d18d13c83ac5ee27008dd079e8ba954f8197ff3c6c[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '483045022100c7f51ff0888c182a1a60c08904d8116c9d2e31cb7d2fd5b63c2bf9fd7b246fc102202ee786d2052448621c4a04d18d13c83ac5ee27008dd079e8ba954f8197ff3c6c412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, { txid: '42d39fbe068a40fe691f987b22fdf04b80f94d71d2fec20a58125e7b1a06d2a9', vout: 0, scriptSig: { asm: '304402201bbfcd0c120ace9b8c7a6f5e77b61236bb1128e2a757f85ba80101885e9c1212022046fed4006dcd6a236034dede77c566acf74824d14b3ee3da884e9bd93884ff93[ALL|FORKID] 02c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', hex: '47304402201bbfcd0c120ace9b8c7a6f5e77b61236bb1128e2a757f85ba80101885e9c1212022046fed4006dcd6a236034dede77c566acf74824d14b3ee3da884e9bd93884ff93412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795', }, sequence: 4294967295, }, ], vout: [ { value: 1.8725994, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, { value: 1.49237053, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, ], hex: '0200000002290eddb5d1228a5511649285198a2e0c944249cb2dbe016efabfb32304893ef6010000006b483045022100c7f51ff0888c182a1a60c08904d8116c9d2e31cb7d2fd5b63c2bf9fd7b246fc102202ee786d2052448621c4a04d18d13c83ac5ee27008dd079e8ba954f8197ff3c6c412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffffa9d2061a7b5e12580ac2fed2714df9804bf0fd227b981f69fe408a06be9fd342000000006a47304402201bbfcd0c120ace9b8c7a6f5e77b61236bb1128e2a757f85ba80101885e9c1212022046fed4006dcd6a236034dede77c566acf74824d14b3ee3da884e9bd93884ff93412102c237f49dd4c812f27b09d69d4c8a4da12744fda8ad63ce151fed2a3f41fd8795ffffffff02245c290b000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac3d2de508000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac00000000', blockhash: '00000000000000008f563edf8604e537fe0d1e80f1c7c2d97dd094824f804ba3', confirmations: 5637, time: 1612567121, blocktime: 1612567121, height: 672077, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'ec9c20c2c5cd5aa4c9261a9f97e68734b175962c4b3d9edc996dd415dd03c2e7', hash: 'ec9c20c2c5cd5aa4c9261a9f97e68734b175962c4b3d9edc996dd415dd03c2e7', version: 2, size: 1405, locktime: 0, vin: [ { txid: '3507d73b0bb82421d64ae79f469943e56f15d7db954ad235f48ede33c718d860', vout: 0, scriptSig: { asm: '3044022000cc5b79e5da60cf4935f3a172089cd9b631b678462ee29091dc610816d059c4022002e3b6f32e825ac04d2907453d6d647a32a995c798df1c68401cc461f6bfbd3a[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '473044022000cc5b79e5da60cf4935f3a172089cd9b631b678462ee29091dc610816d059c4022002e3b6f32e825ac04d2907453d6d647a32a995c798df1c68401cc461f6bfbd3a41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: '8f73a718d907d94e60c5f73f299bd01dc5b1c163c4ebc26b5304e37a1a7f34af', vout: 0, scriptSig: { asm: '3045022100ac50553448f2a5fab1177ed0bc64541b2dba063d04f2d69a8a1d216fb1435e5802202c7f6abd1685a6d81f14ac3bdb0874d214a5f4260719f9c5dc519ac5d8dffd37[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '483045022100ac50553448f2a5fab1177ed0bc64541b2dba063d04f2d69a8a1d216fb1435e5802202c7f6abd1685a6d81f14ac3bdb0874d214a5f4260719f9c5dc519ac5d8dffd3741210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: 'd38b76bacd6aa75ad2d6fcfd994533e54d0541435970eace49486fde9d6ee2e3', vout: 0, scriptSig: { asm: '304502210091836c6cb4c786bd3b74b73e579ddf8b843ba51841e5675fa53608449b67371802203de75f32b684cfe2d2e9cd424ea6eb4f49248e6698365c9364ebf84cd6e50eab[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '48304502210091836c6cb4c786bd3b74b73e579ddf8b843ba51841e5675fa53608449b67371802203de75f32b684cfe2d2e9cd424ea6eb4f49248e6698365c9364ebf84cd6e50eab41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: 'd47607a72d6bc093556fa7f2cec9d67719bd627751d5d27bc53c4eb8eb6f54e5', vout: 0, scriptSig: { asm: '3045022100e7727d9d26c645282553aef27947ad6795bc89b505ad089d617b6f696399352802206c736524a1410ed3e30cf1127f7f02c9a249392f8f8e7c670250472909d1c0d6[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '483045022100e7727d9d26c645282553aef27947ad6795bc89b505ad089d617b6f696399352802206c736524a1410ed3e30cf1127f7f02c9a249392f8f8e7c670250472909d1c0d641210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: '33f246811f794c4b64098a64c698ae5811054b13e289256a18e2d142beef57e7', vout: 1, scriptSig: { asm: '304402203fe78ad5aaeefab7b3b2277eefc4a2ace9c2e92694b46bf4a76927bf2b82017102200ded59336aba269a54865d9fdd99e72081c0318ccbc37bc0fc0c72b60ae35382[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '47304402203fe78ad5aaeefab7b3b2277eefc4a2ace9c2e92694b46bf4a76927bf2b82017102200ded59336aba269a54865d9fdd99e72081c0318ccbc37bc0fc0c72b60ae3538241210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: '25f915d2912524ad602c882211ccaf479d6bf87ef7c24d1be0f325cec3727257', vout: 0, scriptSig: { asm: '30440220670af03605b9495c8ecee357889ceeb137dadaa1662136fdc55c28fe9434e3c60220285195a62811941745a9f93e136e59c96b81d5b0d9525f3d16d001bc0f6fa9bb[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '4730440220670af03605b9495c8ecee357889ceeb137dadaa1662136fdc55c28fe9434e3c60220285195a62811941745a9f93e136e59c96b81d5b0d9525f3d16d001bc0f6fa9bb41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: 'c9044b4d7438d006a722ef85474c8127265eced4f72c7d71c2f714444bc0e1f2', vout: 0, scriptSig: { asm: '304402203f822a0b207ed49e6918663133a18037c24498c2f770c2649333a32f523e259d02203afc42a79d0da123b67f814effeee7c05c7996ea829b3cfa46c5c2e74209c096[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '47304402203f822a0b207ed49e6918663133a18037c24498c2f770c2649333a32f523e259d02203afc42a79d0da123b67f814effeee7c05c7996ea829b3cfa46c5c2e74209c09641210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: '045306f0019ae0d977de7ff17dd55e861b3fe94458693ee2b94ce5dd7003aab9', vout: 0, scriptSig: { asm: '3045022100a7a2cf838a13a19f0e443ca35ac5ee3d55f70edca992f98402a84d4ab5ae1ad90220644a02c746eae7b44a4600199ecbf69f3b0f0bdf8479f461c482d67ef4a84e76[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '483045022100a7a2cf838a13a19f0e443ca35ac5ee3d55f70edca992f98402a84d4ab5ae1ad90220644a02c746eae7b44a4600199ecbf69f3b0f0bdf8479f461c482d67ef4a84e7641210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, { txid: '1452267e57429edcfdcb1184b24becea6ddf8f8a4f8e130dad6248545d9f8e75', vout: 0, scriptSig: { asm: '30440220290701c797eb52ad6721db615c7d6f623c0200be0e6d6802df68c527655475450220446c4a4da9a0df5efcb57711ad61cf6167dfdda937bd0477189be8afedaedd05[ALL|FORKID] 0352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', hex: '4730440220290701c797eb52ad6721db615c7d6f623c0200be0e6d6802df68c527655475450220446c4a4da9a0df5efcb57711ad61cf6167dfdda937bd0477189be8afedaedd0541210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22d', }, sequence: 4294967295, }, ], vout: [ { value: 5.00001874, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 76458db0ed96fe9863fc1ccec9fa2cfab884b0f6 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', ], }, }, { value: 7.52551634, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 6e1da64f04fc29dbe0b8d33a341e05e3afc586eb OP_EQUALVERIFY OP_CHECKSIG', hex: '76a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ], }, }, ], hex: '020000000960d818c733de8ef435d24a95dbd7156fe54399469fe74ad62124b80b3bd70735000000006a473044022000cc5b79e5da60cf4935f3a172089cd9b631b678462ee29091dc610816d059c4022002e3b6f32e825ac04d2907453d6d647a32a995c798df1c68401cc461f6bfbd3a41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffffaf347f1a7ae304536bc2ebc463c1b1c51dd09b293ff7c5604ed907d918a7738f000000006b483045022100ac50553448f2a5fab1177ed0bc64541b2dba063d04f2d69a8a1d216fb1435e5802202c7f6abd1685a6d81f14ac3bdb0874d214a5f4260719f9c5dc519ac5d8dffd3741210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffffe3e26e9dde6f4849ceea70594341054de5334599fdfcd6d25aa76acdba768bd3000000006b48304502210091836c6cb4c786bd3b74b73e579ddf8b843ba51841e5675fa53608449b67371802203de75f32b684cfe2d2e9cd424ea6eb4f49248e6698365c9364ebf84cd6e50eab41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffffe5546febb84e3cc57bd2d5517762bd1977d6c9cef2a76f5593c06b2da70776d4000000006b483045022100e7727d9d26c645282553aef27947ad6795bc89b505ad089d617b6f696399352802206c736524a1410ed3e30cf1127f7f02c9a249392f8f8e7c670250472909d1c0d641210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffffe757efbe42d1e2186a2589e2134b051158ae98c6648a09644b4c791f8146f233010000006a47304402203fe78ad5aaeefab7b3b2277eefc4a2ace9c2e92694b46bf4a76927bf2b82017102200ded59336aba269a54865d9fdd99e72081c0318ccbc37bc0fc0c72b60ae3538241210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffff577272c3ce25f3e01b4dc2f77ef86b9d47afcc1122882c60ad242591d215f925000000006a4730440220670af03605b9495c8ecee357889ceeb137dadaa1662136fdc55c28fe9434e3c60220285195a62811941745a9f93e136e59c96b81d5b0d9525f3d16d001bc0f6fa9bb41210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dfffffffff2e1c04b4414f7c2717d2cf7d4ce5e2627814c4785ef22a706d038744d4b04c9000000006a47304402203f822a0b207ed49e6918663133a18037c24498c2f770c2649333a32f523e259d02203afc42a79d0da123b67f814effeee7c05c7996ea829b3cfa46c5c2e74209c09641210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffffb9aa0370dde54cb9e23e695844e93f1b865ed57df17fde77d9e09a01f0065304000000006b483045022100a7a2cf838a13a19f0e443ca35ac5ee3d55f70edca992f98402a84d4ab5ae1ad90220644a02c746eae7b44a4600199ecbf69f3b0f0bdf8479f461c482d67ef4a84e7641210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffff758e9f5d544862ad0d138e4f8a8fdf6deaec4bb28411cbfddc9e42577e265214000000006a4730440220290701c797eb52ad6721db615c7d6f623c0200be0e6d6802df68c527655475450220446c4a4da9a0df5efcb57711ad61cf6167dfdda937bd0477189be8afedaedd0541210352cbc218d193ceaf4fb38a772856380173db7a908905e3190841b3174c7ae22dffffffff02526ccd1d000000001976a91476458db0ed96fe9863fc1ccec9fa2cfab884b0f688acd206db2c000000001976a9146e1da64f04fc29dbe0b8d33a341e05e3afc586eb88ac00000000', blockhash: '00000000000000008f563edf8604e537fe0d1e80f1c7c2d97dd094824f804ba3', confirmations: 5637, time: 1612567121, blocktime: 1612567121, height: 672077, address: 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', }, { txid: 'dd35690b0cefd24dcc08acba8694ecd49293f365a81372cb66c8f1c1291d97c5', hash: 'dd35690b0cefd24dcc08acba8694ecd49293f365a81372cb66c8f1c1291d97c5', version: 2, size: 224, locktime: 0, vin: [ { txid: 'd3e1b8a65f9d50363cad9a496f7cecab59c9415dd9bcfd6f56c0c5dd4dffa7af', vout: 1, scriptSig: { asm: '3045022100fb14c794778e33aa66b861e85650f07e802da8b257cc37ac9dc1ac6346a0171d022051d79d2fc81bcb5bc3c7c7025d4222ecc2060cbdbf71a6fb2c7856b2eeaef7dc[ALL|FORKID] 02e4af47715f4db1d2a8d686be40c42bba5e70d715e470314181730e797be2324b', hex: '483045022100fb14c794778e33aa66b861e85650f07e802da8b257cc37ac9dc1ac6346a0171d022051d79d2fc81bcb5bc3c7c7025d4222ecc2060cbdbf71a6fb2c7856b2eeaef7dc412102e4af47715f4db1d2a8d686be40c42bba5e70d715e470314181730e797be2324b', }, sequence: 4294967295, }, ], vout: [ { value: 0, n: 0, scriptPubKey: { asm: 'OP_RETURN 62696e676f656c65637472756d', hex: '6a0d62696e676f656c65637472756d', type: 'nulldata', }, }, { value: 0.61759811, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 09401a690d52252acd1152c2ddd36c5081dff574 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91409401a690d52252acd1152c2ddd36c5081dff57488ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qqy5qxnfp4fz22kdz9fv9hwnd3ggrhl4wsekqyswf0', ], }, }, ], hex: '0200000001afa7ff4dddc5c0566ffdbcd95d41c959abec7c6f499aad3c36509d5fa6b8e1d3010000006b483045022100fb14c794778e33aa66b861e85650f07e802da8b257cc37ac9dc1ac6346a0171d022051d79d2fc81bcb5bc3c7c7025d4222ecc2060cbdbf71a6fb2c7856b2eeaef7dc412102e4af47715f4db1d2a8d686be40c42bba5e70d715e470314181730e797be2324bffffffff020000000000000000176a026d021274657374696e67206d6573736167652031324361ae03000000001976a91409401a690d52252acd1152c2ddd36c5081dff57488ac00000000', blockhash: '00000000000000000cbd73d616ecdd107a92d33aee5406ce05141231a76d408a', confirmations: 59, time: 1635507345, blocktime: 1635507345, }, { txid: '5adc33b5c0509b31c6da359177b19467c443bdc4dd37c283c0f87244c0ad63af', hash: '5adc33b5c0509b31c6da359177b19467c443bdc4dd37c283c0f87244c0ad63af', version: 2, size: 223, locktime: 0, vin: [ { txid: '2e1c5d1060bf5216678e31ed52d2ca564b81a34ac1a10749c5e124d25ec3c7a2', vout: 0, scriptSig: { asm: '304402205f6f73369ee558a8dd149480dda3d5417aab3c9bc2c4ff97aaebfc2768ceaded022052d7e2cfa0743205db27ac0cd29bcae110f1ca00aeedb6f88694901a7379dc65[ALL|FORKID] 0320b7867e815a2b00fa935a44a4c348299f7171995c8470d8221e6485da521164', hex: '47304402205f6f73369ee558a8dd149480dda3d5417aab3c9bc2c4ff97aaebfc2768ceaded022052d7e2cfa0743205db27ac0cd29bcae110f1ca00aeedb6f88694901a7379dc6541210320b7867e815a2b00fa935a44a4c348299f7171995c8470d8221e6485da521164', }, sequence: 4294967295, }, ], vout: [ { value: 0, n: 0, scriptPubKey: { asm: 'OP_RETURN 1650553856 63617368746162756c6172', hex: '6a04007461620b63617368746162756c6172', type: 'nulldata', }, value: '0', }, { value: 0.000045, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 b366ef7c1ffd4ef452d72556634720cc8741e1dc OP_EQUALVERIFY OP_CHECKSIG', hex: '76a914b366ef7c1ffd4ef452d72556634720cc8741e1dc88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qzekdmmurl75aazj6uj4vc68yrxgws0pms30lsw8de', ], }, }, ], hex: '0200000001a2c7c35ed224e1c54907a1c14aa3814b56cad252ed318e671652bf60105d1c2e000000006a47304402205f6f73369ee558a8dd149480dda3d5417aab3c9bc2c4ff97aaebfc2768ceaded022052d7e2cfa0743205db27ac0cd29bcae110f1ca00aeedb6f88694901a7379dc6541210320b7867e815a2b00fa935a44a4c348299f7171995c8470d8221e6485da521164ffffffff020000000000000000176a026d021274657374696e67206d65737361676520313394110000000000001976a914b366ef7c1ffd4ef452d72556634720cc8741e1dc88ac00000000', blockhash: '000000000000000012c00755aab6cdef0806ebe24da10d78574c67558d3d816b', confirmations: 70, time: 1635511136, blocktime: 1635511136, }, { txid: '2e1c5d1060bf5216678e31ed52d2ca564b81a34ac1a10749c5e124d25ec3c7a2', hash: '2e1c5d1060bf5216678e31ed52d2ca564b81a34ac1a10749c5e124d25ec3c7a2', version: 2, size: 225, locktime: 0, vin: [ { txid: 'cb879adc0a388416eda7289020b9d7930e9df589db375e95947968673edfb780', vout: 0, scriptSig: { asm: '304402202950a75c2833d0400b38d4f47ad4af727388057bdffba90fe8950e2a69a1df8502206955582de9c6c99663895a114634e4fc03e7f2ede79555a4ad747888cdf1250d[ALL|FORKID] 0295236f3c965a760068a021a45a85f551adc2626ddc51d7539497d568affc148a', hex: '47304402202950a75c2833d0400b38d4f47ad4af727388057bdffba90fe8950e2a69a1df8502206955582de9c6c99663895a114634e4fc03e7f2ede79555a4ad747888cdf1250d41210295236f3c965a760068a021a45a85f551adc2626ddc51d7539497d568affc148a', }, sequence: 4294967295, }, ], vout: [ { value: 0.00005, n: 0, scriptPubKey: { asm: 'OP_DUP OP_HASH160 cd6b0a1b19b12d88e62bd265704b59e38e71fc55 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a914cd6b0a1b19b12d88e62bd265704b59e38e71fc5588ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qrxkkzsmrxcjmz8x90fx2uztt83cuu0u25vrmw66jk', ], }, }, { value: 0.00004545, n: 1, scriptPubKey: { asm: 'OP_DUP OP_HASH160 09401a690d52252acd1152c2ddd36c5081dff574 OP_EQUALVERIFY OP_CHECKSIG', hex: '76a91409401a690d52252acd1152c2ddd36c5081dff57488ac', reqSigs: 1, type: 'pubkeyhash', addresses: [ 'bitcoincash:qqy5qxnfp4fz22kdz9fv9hwnd3ggrhl4wsekqyswf0', ], }, }, ], hex: '020000000180b7df3e67687994955e37db89f59d0e93d7b9209028a7ed1684380adc9a87cb000000006a47304402202950a75c2833d0400b38d4f47ad4af727388057bdffba90fe8950e2a69a1df8502206955582de9c6c99663895a114634e4fc03e7f2ede79555a4ad747888cdf1250d41210295236f3c965a760068a021a45a85f551adc2626ddc51d7539497d568affc148affffffff0288130000000000001976a914cd6b0a1b19b12d88e62bd265704b59e38e71fc5588acc1110000000000001976a91409401a690d52252acd1152c2ddd36c5081dff57488ac00000000', blockhash: '00000000000000000ee5254eb809e121138497bdd9b5053cf2738714220b1ce2', confirmations: 5625, time: 1635509252, blocktime: 1635509252, }, + { + blockhash: + '0000000000000000005aa0636bbc6b0117417d1db091b911259023885b5c0d12', + blocktime: 1643286535, + confirmations: 3, + hash: '8b569d64a7e51d1d3cf1cf2b99d8b34451bbebc7df6b67232e5b770418b0428c', + hex: '0200000002682a61fe5fe1f6468f5ebd9b6bfecd87d37281709e44222583f1868912f9b9ea020000006b483045022100d9bcfbeb1f27565b4cd3609eded0ce36cbac6472aa4fba0551cb9f5eae7ca3460220712263d13a6033a133eb67a96dba1ee626cfc6024ccd66825963692e2abd1144412102394542bf928bc707dcc156acf72e87c9d2fef77eaefc5f6b836d9ceeb0fc6a3effffffff5d4905ee2f09b0c3c131fe1b862f9882c2b8d79c9abf4a0351bbd03bacffd40a020000006a473044022059c39ed0798da7a4788355120d09737468ab182940ec78c3de1a2a23995c99aa02201bde53d7155892a145966149eedba665fbe02475a34b15a84c5d9a3d4b787d97412102394542bf928bc707dcc156acf72e87c9d2fef77eaefc5f6b836d9ceeb0fc6a3effffffff030000000000000000376a04534c500001010453454e44200203c768a66eba24affb19db1375b19388b6a0f9e1103b772de4d9f8f63ba79e08000000000000196422020000000000001976a9140b7d35fda03544a08e65464d54cfae4257eb6db788aca80a0000000000001976a9140b7d35fda03544a08e65464d54cfae4257eb6db788ac00000000', + locktime: 0, + size: 437, + time: 1643286535, + txid: '8b569d64a7e51d1d3cf1cf2b99d8b34451bbebc7df6b67232e5b770418b0428c', + version: 2, + vin: [ + { + scriptSig: { + asm: '3045022100d9bcfbeb1f27565b4cd3609eded0ce36cbac6472aa4fba0551cb9f5eae7ca3460220712263d13a6033a133eb67a96dba1ee626cfc6024ccd66825963692e2abd1144[ALL|FORKID] 02394542bf928bc707dcc156acf72e87c9d2fef77eaefc5f6b836d9ceeb0fc6a3e', + hex: '483045022100d9bcfbeb1f27565b4cd3609eded0ce36cbac6472aa4fba0551cb9f5eae7ca3460220712263d13a6033a133eb67a96dba1ee626cfc6024ccd66825963692e2abd1144412102394542bf928bc707dcc156acf72e87c9d2fef77eaefc5f6b836d9ceeb0fc6a3e', + }, + sequence: 4294967295, + txid: 'eab9f9128986f1832522449e708172d387cdfe6b9bbd5e8f46f6e15ffe612a68', + vout: 2, + }, + { + scriptSig: { + asm: '3044022059c39ed0798da7a4788355120d09737468ab182940ec78c3de1a2a23995c99aa02201bde53d7155892a145966149eedba665fbe02475a34b15a84c5d9a3d4b787d97[ALL|FORKID] 02394542bf928bc707dcc156acf72e87c9d2fef77eaefc5f6b836d9ceeb0fc6a3e', + hex: '473044022059c39ed0798da7a4788355120d09737468ab182940ec78c3de1a2a23995c99aa02201bde53d7155892a145966149eedba665fbe02475a34b15a84c5d9a3d4b787d97412102394542bf928bc707dcc156acf72e87c9d2fef77eaefc5f6b836d9ceeb0fc6a3e', + }, + sequence: 4294967295, + txid: '0ad4ffac3bd0bb51034abf9a9cd7b8c282982f861bfe31c1c3b0092fee05495d', + vout: 2, + }, + ], + vout: [ + { + n: 0, + scriptPubKey: { + asm: 'OP_RETURN 5262419 1 1145980243 0203c768a66eba24affb19db1375b19388b6a0f9e1103b772de4d9f8f63ba79e 0000000000001964', + hex: '6a04534c500001010453454e44200203c768a66eba24affb19db1375b19388b6a0f9e1103b772de4d9f8f63ba79e080000000000001964', + type: 'nulldata', + }, + value: 0, + }, + { + n: 1, + scriptPubKey: { + addresses: [ + 'bitcoincash:qq9h6d0a5q65fgywv4ry64x04ep906mdku7ymranw3', + ], + asm: 'OP_DUP OP_HASH160 0b7d35fda03544a08e65464d54cfae4257eb6db7 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a9140b7d35fda03544a08e65464d54cfae4257eb6db788ac', + reqSigs: 1, + type: 'pubkeyhash', + }, + value: 0.00000546, + }, + { + n: 2, + scriptPubKey: { + addresses: [ + 'bitcoincash:qq9h6d0a5q65fgywv4ry64x04ep906mdku7ymranw3', + ], + asm: 'OP_DUP OP_HASH160 0b7d35fda03544a08e65464d54cfae4257eb6db7 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a9140b7d35fda03544a08e65464d54cfae4257eb6db788ac', + reqSigs: 1, + type: 'pubkeyhash', + }, + value: 0.00002728, + }, + ], + }, ]; diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js index 0c365d6d5..93073e94d 100644 --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -1,622 +1,656 @@ /* eslint-disable no-native-reassign */ import useBCH from '../useBCH'; import mockReturnGetHydratedUtxoDetails from '../__mocks__/mockReturnGetHydratedUtxoDetails'; import mockReturnGetSlpBalancesAndUtxos from '../__mocks__/mockReturnGetSlpBalancesAndUtxos'; import mockReturnGetHydratedUtxoDetailsWithZeroBalance from '../__mocks__/mockReturnGetHydratedUtxoDetailsWithZeroBalance'; import mockReturnGetSlpBalancesAndUtxosNoZeroBalance from '../__mocks__/mockReturnGetSlpBalancesAndUtxosNoZeroBalance'; import sendBCHMock from '../__mocks__/sendBCH'; import createTokenMock from '../__mocks__/createToken'; import mockTxHistory from '../__mocks__/mockTxHistory'; import mockFlatTxHistory from '../__mocks__/mockFlatTxHistory'; import mockTxDataWithPassthrough from '../__mocks__/mockTxDataWithPassthrough'; import mockPublicKeys from '../__mocks__/mockPublicKeys'; import { flattenedHydrateUtxosResponse, legacyHydrateUtxosResponse, } from '../__mocks__/mockHydrateUtxosBatched'; import { tokenSendWdt, tokenReceiveGarmonbozia, tokenReceiveTBS, tokenGenesisCashtabMintAlpha, } from '../__mocks__/mockParseTokenInfoForTxHistory'; import { mockSentCashTx, mockReceivedCashTx, mockSentTokenTx, mockReceivedTokenTx, mockSentOpReturnMessageTx, mockReceivedOpReturnMessageTx, + mockBurnEtokenTx, } from '../__mocks__/mockParsedTxs'; import BCHJS from '@psf/bch-js'; // TODO: should be removed when external lib not needed anymore import { currency } from '../../components/Common/Ticker'; import BigNumber from 'bignumber.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; describe('useBCH hook', () => { it('gets Rest Api Url on testnet', () => { process = { env: { REACT_APP_NETWORK: `testnet`, REACT_APP_BCHA_APIS: 'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/', REACT_APP_BCHA_APIS_TEST: 'https://free-test.fullstack.cash/v3/', }, }; const { getRestUrl } = useBCH(); const expectedApiUrl = `https://free-test.fullstack.cash/v3/`; expect(getRestUrl(0)).toBe(expectedApiUrl); }); it('gets primary Rest API URL on mainnet', () => { process = { env: { REACT_APP_BCHA_APIS: 'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/', REACT_APP_NETWORK: 'mainnet', }, }; const { getRestUrl } = useBCH(); const expectedApiUrl = `https://rest.kingbch.com/v3/`; expect(getRestUrl(0)).toBe(expectedApiUrl); }); it('calculates fee correctly for 2 P2PKH outputs', () => { const { calcFee } = useBCH(); const BCH = new BCHJS(); const utxosMock = [{}, {}]; expect(calcFee(BCH, utxosMock, 2, 1.01)).toBe(378); }); it('gets SLP and BCH balances and utxos from hydrated utxo details', async () => { const { getSlpBalancesAndUtxos } = useBCH(); const BCH = new BCHJS(); const result = await getSlpBalancesAndUtxos( BCH, mockReturnGetHydratedUtxoDetails, ); expect(result).toStrictEqual(mockReturnGetSlpBalancesAndUtxos); }); it(`Ignores SLP utxos with utxo.tokenQty === '0'`, async () => { const { getSlpBalancesAndUtxos } = useBCH(); const BCH = new BCHJS(); const result = await getSlpBalancesAndUtxos( BCH, mockReturnGetHydratedUtxoDetailsWithZeroBalance, ); expect(result).toStrictEqual( mockReturnGetSlpBalancesAndUtxosNoZeroBalance, ); }); it(`Parses flattened batched hydrateUtxosResponse to yield same result as legacy unbatched hydrateUtxosResponse`, async () => { const { getSlpBalancesAndUtxos } = useBCH(); const BCH = new BCHJS(); const batchedResult = await getSlpBalancesAndUtxos( BCH, flattenedHydrateUtxosResponse, ); const legacyResult = await getSlpBalancesAndUtxos( BCH, legacyHydrateUtxosResponse, ); expect(batchedResult).toStrictEqual(legacyResult); }); it('sends XEC correctly', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, expectedHex, utxos, wallet, destinationAddress, sendAmount, } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect( await sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, sendAmount, ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( expectedHex, ); }); it('sends XEC correctly with an encrypted OP_RETURN message', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, utxos, wallet, destinationAddress, sendAmount } = sendBCHMock; const expectedPubKeyResponse = { success: true, publicKey: '03451a3e61ae8eb76b8d4cd6057e4ebaf3ef63ae3fe5f441b72c743b5810b6a389', }; BCH.encryption.getPubKey = jest .fn() .mockResolvedValue(expectedPubKeyResponse); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect( await sendXec( BCH, wallet, utxos, currency.defaultFee, 'This is an encrypted opreturn message', false, null, destinationAddress, sendAmount, true, // encryption flag for the OP_RETURN message ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); }); it('sends one to many XEC correctly', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, expectedHex, utxos, wallet, destinationAddress, sendAmount, } = sendBCHMock; const addressAndValueArray = [ 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6', 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6.8', 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,7', 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6', ]; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect( await sendXec( BCH, wallet, utxos, currency.defaultFee, '', true, addressAndValueArray, ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); }); it(`Throws error if called trying to send one base unit ${currency.ticker} more than available in utxo set`, async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; const expectedTxFeeInSats = 229; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); const oneBaseUnitMoreThanBalance = new BigNumber(utxos[0].value) .minus(expectedTxFeeInSats) .plus(1) .div(10 ** currency.cashDecimals) .toString(); const failedSendBch = sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, oneBaseUnitMoreThanBalance, ); expect(failedSendBch).rejects.toThrow(new Error('Insufficient funds')); const nullValuesSendBch = await sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, null, ); expect(nullValuesSendBch).toBe(null); }); it('Throws error on attempt to send one satoshi less than backend dust limit', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); const failedSendBch = sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, new BigNumber( fromSmallestDenomination(currency.dustSats).toString(), ) .minus(new BigNumber('0.00000001')) .toString(), ); expect(failedSendBch).rejects.toThrow(new Error('dust')); const nullValuesSendBch = await sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, null, ); expect(nullValuesSendBch).toBe(null); }); + it("throws error attempting to burn an eToken ID that is not within the wallet's utxo", async () => { + const { burnEtoken } = useBCH(); + const BCH = new BCHJS(); + const { wallet } = sendBCHMock; + const burnAmount = 10; + const eTokenId = '0203c768a66eba24affNOTVALID103b772de4d9f8f63ba79e'; + const expectedError = + 'No token UTXOs for the specified token could be found.'; + + let thrownError; + try { + await burnEtoken(BCH, wallet, mockReturnGetSlpBalancesAndUtxos, { + eTokenId, + burnAmount, + }); + } catch (err) { + thrownError = err; + } + expect(thrownError).toStrictEqual(new Error(expectedError)); + }); + it('receives errors from the network and parses it', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); const { sendAmount, utxos, wallet, destinationAddress } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('insufficient priority (code 66)'); }); const insufficientPriority = sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, sendAmount, ); await expect(insufficientPriority).rejects.toThrow( new Error('insufficient priority (code 66)'), ); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('txn-mempool-conflict (code 18)'); }); const txnMempoolConflict = sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, sendAmount, ); await expect(txnMempoolConflict).rejects.toThrow( new Error('txn-mempool-conflict (code 18)'), ); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('Network Error'); }); const networkError = sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, sendAmount, ); await expect(networkError).rejects.toThrow(new Error('Network Error')); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { const err = new Error( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', ); throw err; }); const tooManyAncestorsMempool = sendXec( BCH, wallet, utxos, currency.defaultFee, '', false, null, destinationAddress, sendAmount, ); await expect(tooManyAncestorsMempool).rejects.toThrow( new Error( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', ), ); }); it('creates a token correctly', async () => { const { createToken } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, expectedHex, wallet, configObj } = createTokenMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect(await createToken(BCH, wallet, 5.01, configObj)).toBe( `${currency.tokenExplorerUrl}/tx/${expectedTxId}`, ); expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( expectedHex, ); }); it('Throws correct error if user attempts to create a token with an invalid wallet', async () => { const { createToken } = useBCH(); const BCH = new BCHJS(); const { invalidWallet, configObj } = createTokenMock; const invalidWalletTokenCreation = createToken( BCH, invalidWallet, currency.defaultFee, configObj, ); await expect(invalidWalletTokenCreation).rejects.toThrow( new Error('Invalid wallet'), ); }); it('Correctly flattens transaction history', () => { const { flattenTransactions } = useBCH(); expect(flattenTransactions(mockTxHistory, 10)).toStrictEqual( mockFlatTxHistory, ); }); it(`Correctly parses a "send ${currency.ticker}" transaction`, async () => { const { parseTxData } = useBCH(); const BCH = new BCHJS(); expect( await parseTxData( BCH, [mockTxDataWithPassthrough[0]], mockPublicKeys, ), ).toStrictEqual(mockSentCashTx); }); it(`Correctly parses a "receive ${currency.ticker}" transaction`, async () => { const { parseTxData } = useBCH(); const BCH = new BCHJS(); expect( await parseTxData( BCH, [mockTxDataWithPassthrough[5]], mockPublicKeys, ), ).toStrictEqual(mockReceivedCashTx); }); it(`Correctly parses a "send ${currency.tokenTicker}" transaction`, async () => { const { parseTxData } = useBCH(); const BCH = new BCHJS(); expect( await parseTxData( BCH, [mockTxDataWithPassthrough[1]], mockPublicKeys, ), ).toStrictEqual(mockSentTokenTx); }); + it(`Correctly parses a "burn ${currency.tokenTicker}" transaction`, async () => { + const { parseTxData } = useBCH(); + const BCH = new BCHJS(); + expect( + await parseTxData( + BCH, + [mockTxDataWithPassthrough[13]], + mockPublicKeys, + ), + ).toStrictEqual(mockBurnEtokenTx); + }); + it(`Correctly parses a "receive ${currency.tokenTicker}" transaction`, async () => { const { parseTxData } = useBCH(); const BCH = new BCHJS(); expect( await parseTxData( BCH, [mockTxDataWithPassthrough[3]], mockPublicKeys, ), ).toStrictEqual(mockReceivedTokenTx); }); it(`Correctly parses a "send ${currency.tokenTicker}" transaction with token details`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenSendWdt.parsedTx, tokenSendWdt.tokenInfo, ), ).toStrictEqual(tokenSendWdt.cashtabTokenInfo); }); it(`Correctly parses a "receive ${currency.tokenTicker}" transaction with token details and 9 decimals of precision`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenReceiveTBS.parsedTx, tokenReceiveTBS.tokenInfo, ), ).toStrictEqual(tokenReceiveTBS.cashtabTokenInfo); }); it(`Correctly parses a "receive ${currency.tokenTicker}" transaction from an HD wallet (change address different from sending address)`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenReceiveGarmonbozia.parsedTx, tokenReceiveGarmonbozia.tokenInfo, ), ).toStrictEqual(tokenReceiveGarmonbozia.cashtabTokenInfo); }); it(`Correctly parses a "GENESIS ${currency.tokenTicker}" transaction with token details`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenGenesisCashtabMintAlpha.parsedTx, tokenGenesisCashtabMintAlpha.tokenInfo, ), ).toStrictEqual(tokenGenesisCashtabMintAlpha.cashtabTokenInfo); }); it(`Correctly parses a "send ${currency.ticker}" transaction with an OP_RETURN message`, async () => { const { parseTxData } = useBCH(); const BCH = new BCHJS(); expect( await parseTxData( BCH, [mockTxDataWithPassthrough[10]], mockPublicKeys, ), ).toStrictEqual(mockSentOpReturnMessageTx); }); it(`Correctly parses a "receive ${currency.ticker}" transaction with an OP_RETURN message`, async () => { const { parseTxData } = useBCH(); const BCH = new BCHJS(); BCH.RawTransactions.getRawTransaction = jest .fn() .mockResolvedValue(mockTxDataWithPassthrough[12]); expect( await parseTxData( BCH, [mockTxDataWithPassthrough[11]], mockPublicKeys, ), ).toStrictEqual(mockReceivedOpReturnMessageTx); }); it(`handleEncryptedOpReturn() correctly encrypts a message based on a valid cash address`, async () => { const { handleEncryptedOpReturn } = useBCH(); const BCH = new BCHJS(); const destinationAddress = 'bitcoincash:qqvuj09f80sw9j7qru84ptxf0hyqffc38gstxfs5ru'; const message = 'This message is encrypted by ecies-lite with default parameters'; const expectedPubKeyResponse = { success: true, publicKey: '03208c4f52229e021ddec5fc6e07a59fd66388ac52bc2a2c1e0f1afb24b0e275ac', }; BCH.encryption.getPubKey = jest .fn() .mockResolvedValue(expectedPubKeyResponse); const result = await handleEncryptedOpReturn( BCH, destinationAddress, Buffer.from(message), ); // loop through each ecies encryption parameter from the object returned from the handleEncryptedOpReturn() call for (const k of Object.keys(result)) { switch (result[k].toString()) { case 'epk': // verify the sender's ephemeral public key buffer expect(result[k].toString()).toEqual( 'BPxEy0o7QsRok2GSpuLU27g0EqLIhf6LIxHx7P5UTZF9EFuQbqGzr5cCA51qVnvIJ9CZ84iW1DeDdvhg/EfPSas=', ); break; case 'iv': // verify the initialization vector for the cipher algorithm expect(result[k].toString()).toEqual( '2FcU3fRZUOBt7dqshZjd+g==', ); break; case 'ct': // verify the encrypted message buffer expect(result[k].toString()).toEqual( 'wVxPjv/ZiQ4etHqqTTIEoKvYYf4po05I/kNySrdsN3verxlHI07Rbob/VfF4MDfYHpYmDwlR9ax1shhdSzUG/A==', ); break; case 'mac': // verify integrity of the message (checksum) expect(result[k].toString()).toEqual( 'F9KxuR48O0wxa9tFYq6/Hy3joI2edKxLFSeDVk6JKZE=', ); break; } } }); it(`getRecipientPublicKey() correctly retrieves the public key of a cash address`, async () => { const { getRecipientPublicKey } = useBCH(); const BCH = new BCHJS(); const expectedPubKeyResponse = { success: true, publicKey: '03208c4f52229e021ddec5fc6e07a59fd66388ac52bc2a2c1e0f1afb24b0e275ac', }; const expectedPubKey = '03208c4f52229e021ddec5fc6e07a59fd66388ac52bc2a2c1e0f1afb24b0e275ac'; const destinationAddress = 'bitcoincash:qqvuj09f80sw9j7qru84ptxf0hyqffc38gstxfs5ru'; BCH.encryption.getPubKey = jest .fn() .mockResolvedValue(expectedPubKeyResponse); expect(await getRecipientPublicKey(BCH, destinationAddress)).toBe( expectedPubKey, ); }); }); diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js index 4b29fed2f..50b8963b7 100644 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -1,1518 +1,1675 @@ import BigNumber from 'bignumber.js'; import { currency } from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; import { toSmallestDenomination, fromSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, isValidStoredWallet, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, convertToEncryptStruct, getPublicKey, parseOpReturn, } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; import ecies from 'ecies-lite'; import wif from 'wif'; export default function useBCH() { const SEND_BCH_ERRORS = { INSUFFICIENT_FUNDS: 0, NETWORK_ERROR: 1, INSUFFICIENT_PRIORITY: 66, // ~insufficient fee DOUBLE_SPENDING: 18, MAX_UNCONFIRMED_TXS: 64, }; const getRestUrl = (apiIndex = 0) => { const apiString = process.env.REACT_APP_NETWORK === `mainnet` ? process.env.REACT_APP_BCHA_APIS : process.env.REACT_APP_BCHA_APIS_TEST; const apiArray = apiString.split(','); return apiArray[apiIndex]; }; const flattenTransactions = ( txHistory, txCount = currency.txHistoryCount, ) => { /* Convert txHistory, format [{address: '', transactions: [{height: '', tx_hash: ''}, ...{}]}, {}, {}] to flatTxHistory [{txid: '', blockheight: '', address: ''}] sorted by blockheight, newest transactions to oldest transactions */ let flatTxHistory = []; let includedTxids = []; for (let i = 0; i < txHistory.length; i += 1) { const { address, transactions } = txHistory[i]; for (let j = transactions.length - 1; j >= 0; j -= 1) { let flatTx = {}; flatTx.address = address; // If tx is unconfirmed, give arbitrarily high blockheight flatTx.height = transactions[j].height <= 0 ? 10000000 : transactions[j].height; flatTx.txid = transactions[j].tx_hash; // Only add this tx if the same transaction is not already in the array // This edge case can happen with older wallets, txs can be on multiple paths if (!includedTxids.includes(flatTx.txid)) { includedTxids.push(flatTx.txid); flatTxHistory.push(flatTx); } } } // Sort with most recent transaction at index 0 flatTxHistory.sort((a, b) => b.height - a.height); // Only return 10 return flatTxHistory.splice(0, txCount); }; const parseTxData = async (BCH, txData, publicKeys, wallet) => { /* Desired output [ { txid: '', type: send, receive receivingAddress: '', quantity: amount bcha token: true/false tokenInfo: { tokenId: tokenQty: txType: mint, send, other } opReturnMessage: 'message extracted from asm' or '' } ] */ const parsedTxHistory = []; for (let i = 0; i < txData.length; i += 1) { const tx = txData[i]; const parsedTx = {}; // Move over info that does not need to be calculated parsedTx.txid = tx.txid; parsedTx.height = tx.height; let destinationAddress = tx.address; // if there was an error in getting the tx data from api, the tx will only have txid and height // So, it will not have 'vin' if (!Object.keys(tx).includes('vin')) { // Populate as a limited-info tx that can be expanded in a block explorer parsedTxHistory.push(parsedTx); continue; } parsedTx.confirmations = tx.confirmations; parsedTx.blocktime = tx.blocktime; let amountSent = 0; let amountReceived = 0; let opReturnMessage = ''; let isCashtabMessage = false; let isEncryptedMessage = false; let decryptionSuccess = false; // Assume an incoming transaction let outgoingTx = false; let tokenTx = false; let substring = ''; // If vin's scriptSig contains one of the publicKeys of this wallet // This is an outgoing tx for (let j = 0; j < tx.vin.length; j += 1) { // Since Cashtab only concerns with utxos of Path145, Path245 and Path1899 addresses, // which are hashes of thier public keys. We can safely assume that Cashtab can only // consumes utxos of type 'pubkeyhash' // Therefore, only tx with vin's scriptSig of type 'pubkeyhash' can potentially be an outgoing tx. // any other scriptSig type indicates that the tx is incoming. try { const thisInputScriptSig = tx.vin[j].scriptSig; let inputPubKey = undefined; const inputType = BCH.Script.classifyInput( BCH.Script.decode( Buffer.from(thisInputScriptSig.hex, 'hex'), ), ); if (inputType === 'pubkeyhash') { inputPubKey = thisInputScriptSig.hex.substring( thisInputScriptSig.hex.length - 66, ); } publicKeys.forEach(pubKey => { if (pubKey === inputPubKey) { // This is an outgoing transaction outgoingTx = true; } }); if (outgoingTx === true) break; } catch (err) { console.log( "useBCH.parsedTxHistory() error: in trying to classify Input' scriptSig", ); } } // Iterate over vout to find how much was sent or received for (let j = 0; j < tx.vout.length; j += 1) { const thisOutput = tx.vout[j]; // If there is no addresses object in the output, it's either an OP_RETURN msg or token tx if ( !Object.keys(thisOutput.scriptPubKey).includes('addresses') ) { let hex = thisOutput.scriptPubKey.hex; let parsedOpReturnArray = parseOpReturn(hex); if (!parsedOpReturnArray) { console.log( 'useBCH.parsedTxData() error: parsed array is empty', ); break; } let message = ''; let txType = parsedOpReturnArray[0]; if (txType === currency.opReturn.appPrefixesHex.eToken) { // this is an eToken transaction tokenTx = true; } else if ( txType === currency.opReturn.appPrefixesHex.cashtab ) { // this is a Cashtab message try { opReturnMessage = Buffer.from( parsedOpReturnArray[1], 'hex', ); isCashtabMessage = true; } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxData() error: invalid cashtab msg hex: ' + parsedOpReturnArray[1], ); } } else if ( txType === currency.opReturn.appPrefixesHex.cashtabEncrypted ) { // this is an encrypted Cashtab message let msgString = parsedOpReturnArray[1]; let fundingWif, privateKeyObj, privateKeyBuff; if ( wallet && wallet.state && wallet.state.slpBalancesAndUtxos && wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0] ) { fundingWif = wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0] .wif; privateKeyObj = wif.decode(fundingWif); privateKeyBuff = privateKeyObj.privateKey; if (!privateKeyBuff) { throw new Error('Private key extraction error'); } } else { break; } let structData; let decryptedMessage; try { // Convert the hex encoded message to a buffer const msgBuf = Buffer.from(msgString, 'hex'); // Convert the bufer into a structured object. structData = convertToEncryptStruct(msgBuf); decryptedMessage = await ecies.decrypt( privateKeyBuff, structData, ); decryptionSuccess = true; } catch (err) { console.log( 'useBCH.parsedTxData() decryption error: ' + err, ); decryptedMessage = 'Only the message recipient can view this'; } isCashtabMessage = true; isEncryptedMessage = true; opReturnMessage = decryptedMessage; } else { // this is an externally generated message message = txType; // index 0 is the message content in this instance // if there are more than one part to the external message const arrayLength = parsedOpReturnArray.length; for (let i = 1; i < arrayLength; i++) { message = message + parsedOpReturnArray[i]; } try { opReturnMessage = Buffer.from(message, 'hex'); } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxData() error: invalid external msg hex: ' + substring, ); } } continue; // skipping the remainder of tx data parsing logic in both token and OP_RETURN tx cases } if ( thisOutput.scriptPubKey.addresses && thisOutput.scriptPubKey.addresses[0] === tx.address ) { if (outgoingTx) { // This amount is change continue; } amountReceived += thisOutput.value; } else if (outgoingTx) { amountSent += thisOutput.value; // Assume there's only one destination address, i.e. it was sent by a Cashtab wallet destinationAddress = thisOutput.scriptPubKey.addresses[0]; } } // If the tx is incoming and have a message attached // get the address of the sender for this tx and encode into eCash address let senderAddress = null; if (!outgoingTx && opReturnMessage !== '') { const firstVin = tx.vin[0]; try { // get the tx that generated the first vin of this tx const firstVinTxData = await BCH.RawTransactions.getRawTransaction( firstVin.txid, true, ); // extract the address of the tx output let senderBchAddress = firstVinTxData.vout[firstVin.vout].scriptPubKey .addresses[0]; const { type, hash } = cashaddr.decode(senderBchAddress); senderAddress = cashaddr.encode('ecash', type, hash); } catch (err) { console.log( `Error in BCH.RawTransactions.getRawTransaction(${firstVin.txid}, true)`, ); } } // Construct parsedTx parsedTx.amountSent = amountSent; parsedTx.amountReceived = amountReceived; parsedTx.tokenTx = tokenTx; parsedTx.outgoingTx = outgoingTx; parsedTx.replyAddress = senderAddress; parsedTx.destinationAddress = destinationAddress; parsedTx.opReturnMessage = Buffer.from(opReturnMessage).toString(); parsedTx.isCashtabMessage = isCashtabMessage; parsedTx.isEncryptedMessage = isEncryptedMessage; parsedTx.decryptionSuccess = decryptionSuccess; parsedTxHistory.push(parsedTx); } return parsedTxHistory; }; const getTxHistory = async (BCH, addresses) => { let txHistoryResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); txHistoryResponse = await BCH.Electrumx.transactions(addresses); //console.log(`BCH.Electrumx.transactions(addresses) succeeded`); //console.log(`txHistoryResponse`, txHistoryResponse); if (txHistoryResponse.success && txHistoryResponse.transactions) { return txHistoryResponse.transactions; } else { // eslint-disable-next-line no-throw-literal throw new Error('Error in getTxHistory'); } } catch (err) { console.log(`Error in BCH.Electrumx.transactions(addresses):`); console.log(err); return err; } }; const getTxDataWithPassThrough = async (BCH, flatTx) => { // necessary as BCH.RawTransactions.getRawTransaction does not return address or blockheight let txDataWithPassThrough = {}; try { txDataWithPassThrough = await BCH.RawTransactions.getRawTransaction( flatTx.txid, true, ); } catch (err) { console.log( `Error in BCH.RawTransactions.getRawTransaction(${flatTx.txid}, true)`, ); console.log(err); // Include txid if you don't get it from the attempted response txDataWithPassThrough.txid = flatTx.txid; } txDataWithPassThrough.height = flatTx.height; txDataWithPassThrough.address = flatTx.address; return txDataWithPassThrough; }; const getTxData = async (BCH, txHistory, publicKeys, wallet) => { // Flatten tx history let flatTxs = flattenTransactions(txHistory); // Build array of promises to get tx data for all 10 transactions let getTxDataWithPassThroughPromises = []; for (let i = 0; i < flatTxs.length; i += 1) { const getTxDataWithPassThroughPromise = returnGetTxDataWithPassThroughPromise(BCH, flatTxs[i]); getTxDataWithPassThroughPromises.push( getTxDataWithPassThroughPromise, ); } // Get txData for the 10 most recent transactions let getTxDataWithPassThroughPromisesResponse; try { getTxDataWithPassThroughPromisesResponse = await Promise.all( getTxDataWithPassThroughPromises, ); const parsed = parseTxData( BCH, getTxDataWithPassThroughPromisesResponse, publicKeys, wallet, ); return parsed; } catch (err) { console.log( `Error in Promise.all(getTxDataWithPassThroughPromises):`, ); console.log(err); return err; } }; const parseTokenInfoForTxHistory = (BCH, parsedTx, tokenInfo) => { // Address at which the eToken was received const { destinationAddress } = parsedTx; // Here in cashtab, destinationAddress is in bitcoincash: format // In the API response of tokenInfo, this will be in simpleledger: format // So, must convert to simpleledger const receivingSlpAddress = BCH.SLP.Address.toSLPAddress(destinationAddress); const { transactionType, sendInputsFull, sendOutputsFull } = tokenInfo; const sendingTokenAddresses = []; // Scan over inputs to find out originating addresses for (let i = 0; i < sendInputsFull.length; i += 1) { const sendingAddress = sendInputsFull[i].address; sendingTokenAddresses.push(sendingAddress); } // Scan over outputs to find out how much was sent let qtySent = new BigNumber(0); let qtyReceived = new BigNumber(0); for (let i = 0; i < sendOutputsFull.length; i += 1) { if (sendingTokenAddresses.includes(sendOutputsFull[i].address)) { // token change and should be ignored, unless it's a genesis transaction // then this is the amount created if (transactionType === 'GENESIS') { qtyReceived = qtyReceived.plus( new BigNumber(sendOutputsFull[i].amount), ); } continue; } if (parsedTx.outgoingTx) { qtySent = qtySent.plus( new BigNumber(sendOutputsFull[i].amount), ); } else { // Only if this matches the receiving address if (sendOutputsFull[i].address === receivingSlpAddress) { qtyReceived = qtyReceived.plus( new BigNumber(sendOutputsFull[i].amount), ); } } } const cashtabTokenInfo = {}; cashtabTokenInfo.qtySent = qtySent.toString(); cashtabTokenInfo.qtyReceived = qtyReceived.toString(); cashtabTokenInfo.tokenId = tokenInfo.tokenIdHex; cashtabTokenInfo.tokenName = tokenInfo.tokenName; cashtabTokenInfo.tokenTicker = tokenInfo.tokenTicker; cashtabTokenInfo.transactionType = transactionType; return cashtabTokenInfo; }; const addTokenTxDataToSingleTx = async (BCH, parsedTx) => { // Accept one parsedTx // If it's not a token tx, just return it as is and do not parse for token data if (!parsedTx.tokenTx) { return parsedTx; } // If it could be a token tx, do an API call to get token info and return it let tokenData; try { tokenData = await BCH.SLP.Utils.txDetails(parsedTx.txid); } catch (err) { console.log( `Error in parsing BCH.SLP.Utils.txDetails(${parsedTx.txid})`, ); console.log(err); // This is not a token tx parsedTx.tokenTx = false; return parsedTx; } const { tokenInfo } = tokenData; parsedTx.tokenInfo = parseTokenInfoForTxHistory( BCH, parsedTx, tokenInfo, ); return parsedTx; }; const addTokenTxData = async (BCH, parsedTxs) => { // Collect all txids for token transactions into array of promises // Promise.all to get their tx history // Add a tokeninfo object to parsedTxs for token txs // Get txData for the 10 most recent transactions // Build array of promises to get tx data for all 10 transactions let addTokenTxDataToSingleTxPromises = []; for (let i = 0; i < parsedTxs.length; i += 1) { const addTokenTxDataToSingleTxPromise = returnAddTokenTxDataToSingleTxPromise(BCH, parsedTxs[i]); addTokenTxDataToSingleTxPromises.push( addTokenTxDataToSingleTxPromise, ); } let addTokenTxDataToSingleTxPromisesResponse; try { addTokenTxDataToSingleTxPromisesResponse = await Promise.all( addTokenTxDataToSingleTxPromises, ); return addTokenTxDataToSingleTxPromisesResponse; } catch (err) { console.log( `Error in Promise.all(addTokenTxDataToSingleTxPromises):`, ); console.log(err); return err; } }; // Split out the BCH.Electrumx.utxo(addresses) call from the getSlpBalancesandUtxos function // If utxo set has not changed, you do not need to hydrate the utxo set // This drastically reduces calls to the API const getUtxos = async (BCH, addresses) => { let utxosResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); utxosResponse = await BCH.Electrumx.utxo(addresses); //console.log(`BCH.Electrumx.utxo(addresses) succeeded`); //console.log(`utxosResponse`, utxosResponse); return utxosResponse.utxos; } catch (err) { console.log(`Error in BCH.Electrumx.utxo(addresses):`); return err; } }; const getHydratedUtxoDetails = async (BCH, utxos) => { const hydrateUtxosPromises = []; for (let i = 0; i < utxos.length; i += 1) { let thisAddress = utxos[i].address; let theseUtxos = utxos[i].utxos; const batchedUtxos = batchArray( theseUtxos, currency.xecApiBatchSize, ); // Iterate over each utxo in this address field for (let j = 0; j < batchedUtxos.length; j += 1) { const utxoSetForThisPromise = [ { utxos: batchedUtxos[j], address: thisAddress }, ]; const hydrateUtxosPromise = returnHydrateUtxosPromise( BCH, utxoSetForThisPromise, ); hydrateUtxosPromises.push(hydrateUtxosPromise); } } let hydrateUtxosPromisesResponse; try { hydrateUtxosPromisesResponse = await Promise.all( hydrateUtxosPromises, ); const flattenedBatchedHydratedUtxos = flattenBatchedHydratedUtxos( hydrateUtxosPromisesResponse, ); return flattenedBatchedHydratedUtxos; } catch (err) { console.log(`Error in Promise.all(hydrateUtxosPromises)`); console.log(err); return err; } }; const returnTxDataPromise = (BCH, txidBatch) => { return new Promise((resolve, reject) => { BCH.Electrumx.txData(txidBatch).then( result => { resolve(result); }, err => { reject(err); }, ); }); }; const returnGetTxDataWithPassThroughPromise = (BCH, flatTx) => { return new Promise((resolve, reject) => { getTxDataWithPassThrough(BCH, flatTx).then( result => { resolve(result); }, err => { reject(err); }, ); }); }; const returnAddTokenTxDataToSingleTxPromise = (BCH, parsedTx) => { return new Promise((resolve, reject) => { addTokenTxDataToSingleTx(BCH, parsedTx).then( result => { resolve(result); }, err => { reject(err); }, ); }); }; const returnHydrateUtxosPromise = (BCH, utxoSetForThisPromise) => { return new Promise((resolve, reject) => { BCH.SLP.Utils.hydrateUtxos(utxoSetForThisPromise).then( result => { resolve(result); }, err => { reject(err); }, ); }); }; const fetchTxDataForNullUtxos = async (BCH, nullUtxos) => { // Check nullUtxos. If they aren't eToken txs, count them console.log( `Null utxos found, checking OP_RETURN fields to confirm they are not eToken txs.`, ); const txids = []; for (let i = 0; i < nullUtxos.length; i += 1) { // Batch API call to get their OP_RETURN asm info txids.push(nullUtxos[i].tx_hash); } // segment the txids array into chunks under the api limit const batchedTxids = batchArray(txids, currency.xecApiBatchSize); // build an array of promises let txDataPromises = []; // loop through each batch of 20 txids for (let j = 0; j < batchedTxids.length; j += 1) { const txidsForThisPromise = batchedTxids[j]; // build the promise for the api call with the 20 txids in current batch const txDataPromise = returnTxDataPromise(BCH, txidsForThisPromise); txDataPromises.push(txDataPromise); } try { const txDataPromisesResponse = await Promise.all(txDataPromises); // Scan tx data for each utxo to confirm they are not eToken txs let thisTxDataResult; let nonEtokenUtxos = []; for (let k = 0; k < txDataPromisesResponse.length; k += 1) { thisTxDataResult = txDataPromisesResponse[k].transactions; nonEtokenUtxos = nonEtokenUtxos.concat( checkNullUtxosForTokenStatus(thisTxDataResult), ); } return nonEtokenUtxos; } catch (err) { console.log( `Error in checkNullUtxosForTokenStatus(nullUtxos)` + err, ); console.log(`nullUtxos`, nullUtxos); // If error, ignore these utxos, will be updated next utxo set refresh return []; } }; const getSlpBalancesAndUtxos = async (BCH, hydratedUtxoDetails) => { let hydratedUtxos = []; for (let i = 0; i < hydratedUtxoDetails.slpUtxos.length; i += 1) { const hydratedUtxosAtAddress = hydratedUtxoDetails.slpUtxos[i]; for (let j = 0; j < hydratedUtxosAtAddress.utxos.length; j += 1) { const hydratedUtxo = hydratedUtxosAtAddress.utxos[j]; hydratedUtxo.address = hydratedUtxosAtAddress.address; hydratedUtxos.push(hydratedUtxo); } } //console.log(`hydratedUtxos`, hydratedUtxos); // WARNING // If you hit rate limits, your above utxos object will come back with `isValid` as null, but otherwise ok // You need to throw an error before setting nonSlpUtxos and slpUtxos in this case const nullUtxos = hydratedUtxos.filter(utxo => utxo.isValid === null); if (nullUtxos.length > 0) { console.log(`${nullUtxos.length} null utxos found!`); console.log('nullUtxos', nullUtxos); const nullNonEtokenUtxos = await fetchTxDataForNullUtxos( BCH, nullUtxos, ); // Set isValid === false for nullUtxos that are confirmed non-eToken hydratedUtxos = confirmNonEtokenUtxos( hydratedUtxos, nullNonEtokenUtxos, ); } // Prevent app from treating slpUtxos as nonSlpUtxos // Must enforce === false as api will occasionally return utxo.isValid === null // Do not classify any utxos that include token information as nonSlpUtxos const nonSlpUtxos = hydratedUtxos.filter( utxo => utxo.isValid === false && utxo.value !== currency.etokenSats && !utxo.tokenName, ); // To be included in slpUtxos, the utxo must // have utxo.isValid = true // If utxo has a utxo.tokenQty field, i.e. not a minting baton, then utxo.tokenQty !== '0' const slpUtxos = hydratedUtxos.filter( utxo => utxo.isValid && !(utxo.tokenQty === '0'), ); let tokensById = {}; slpUtxos.forEach(slpUtxo => { let token = tokensById[slpUtxo.tokenId]; if (token) { // Minting baton does nto have a slpUtxo.tokenQty type if (slpUtxo.tokenQty) { token.balance = token.balance.plus( new BigNumber(slpUtxo.tokenQty), ); } //token.hasBaton = slpUtxo.transactionType === "genesis"; if (slpUtxo.utxoType && !token.hasBaton) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } // Examples of slpUtxo /* Genesis transaction: { address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" decimals: 9 height: 617564 isValid: true satoshis: 546 tokenDocumentHash: "" tokenDocumentUrl: "developer.bitcoin.com" tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tokenName: "PiticoLaunch" tokenTicker: "PTCL" tokenType: 1 tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tx_pos: 2 txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" utxoType: "minting-baton" value: 546 vout: 2 } Send transaction: { address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" decimals: 9 height: 655115 isValid: true satoshis: 546 tokenDocumentHash: "" tokenDocumentUrl: "developer.bitcoin.com" tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tokenName: "PiticoLaunch" tokenQty: 1.123456789 tokenTicker: "PTCL" tokenType: 1 transactionType: "send" tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" tx_pos: 1 txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" utxoType: "token" value: 546 vout: 1 } */ } else { token = {}; token.info = slpUtxo; token.tokenId = slpUtxo.tokenId; if (slpUtxo.tokenQty) { token.balance = new BigNumber(slpUtxo.tokenQty); } else { token.balance = new BigNumber(0); } if (slpUtxo.utxoType) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } else { token.hasBaton = false; } tokensById[slpUtxo.tokenId] = token; } }); const tokens = Object.values(tokensById); // console.log(`tokens`, tokens); return { tokens, nonSlpUtxos, slpUtxos, }; }; const calcFee = ( BCH, utxos, p2pkhOutputNumber = 2, satoshisPerByte = currency.defaultFee, ) => { const byteCount = BCH.BitcoinCash.getByteCount( { P2PKH: utxos.length }, { P2PKH: p2pkhOutputNumber }, ); const txFee = Math.ceil(satoshisPerByte * byteCount); return txFee; }; const createToken = async (BCH, wallet, feeInSatsPerByte, configObj) => { try { // Throw error if wallet does not have utxo set in state if (!isValidStoredWallet(wallet)) { const walletError = new Error(`Invalid wallet`); throw walletError; } const utxos = wallet.state.slpBalancesAndUtxos.nonSlpUtxos; const CREATION_ADDR = wallet.Path1899.cashAddress; const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(new BigNumber(utxo.value)); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(BCH, inputUtxos, 3, feeInSatsPerByte); if ( originalAmount .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)) .gte(0) ) { break; } } // amount to send back to the remainder address. const remainder = originalAmount .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)); if (remainder.lt(0)) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; throw error; } // Generate the OP_RETURN entry for an SLP GENESIS transaction. const script = BCH.SLP.TokenType1.generateGenesisOpReturn(configObj); // OP_RETURN needs to be the first output in the transaction. transactionBuilder.addOutput(script, 0); // add output w/ address and amount to send transactionBuilder.addOutput(CREATION_ADDR, currency.etokenSats); // Send change to own address if (remainder.gte(new BigNumber(currency.etokenSats))) { transactionBuilder.addOutput( CREATION_ADDR, parseInt(remainder), ); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.tokenExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; // No unit tests for this function as it is only an API wrapper // Return false if do not get a valid response const getTokenStats = async (BCH, tokenId) => { let tokenStats; try { tokenStats = await BCH.SLP.Utils.tokenStats(tokenId); if (isValidTokenStats(tokenStats)) { return tokenStats; } } catch (err) { console.log(`Error fetching token stats for tokenId ${tokenId}`); console.log(err); return false; } }; const sendToken = async ( BCH, wallet, slpBalancesAndUtxos, { tokenId, amount, tokenReceiverAddress }, ) => { // Handle error of user having no BCH if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { throw new Error( `You need some ${currency.ticker} to send ${currency.tokenTicker}`, ); } const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previous, current) => previous.value > current.value ? previous : current, ); const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter(utxo => { if ( utxo && // UTXO is associated with a token. utxo.tokenId === tokenId && // UTXO matches the token ID. utxo.utxoType === 'token' // UTXO is not a minting baton. ) { return true; } return false; }); if (tokenUtxos.length === 0) { throw new Error( 'No token UTXOs for the specified token could be found.', ); } // BEGIN transaction construction. // instance of transaction builder let transactionBuilder; if (process.env.REACT_APP_NETWORK === 'mainnet') { transactionBuilder = new BCH.TransactionBuilder(); } else transactionBuilder = new BCH.TransactionBuilder('testnet'); const originalAmount = largestBchUtxo.value; transactionBuilder.addInput( largestBchUtxo.tx_hash, largestBchUtxo.tx_pos, ); let finalTokenAmountSent = new BigNumber(0); let tokenAmountBeingSentToAddress = new BigNumber(amount); let tokenUtxosBeingSpent = []; for (let i = 0; i < tokenUtxos.length; i++) { finalTokenAmountSent = finalTokenAmountSent.plus( new BigNumber(tokenUtxos[i].tokenQty), ); transactionBuilder.addInput( tokenUtxos[i].tx_hash, tokenUtxos[i].tx_pos, ); tokenUtxosBeingSpent.push(tokenUtxos[i]); if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { break; } } const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn( tokenUtxosBeingSpent, tokenAmountBeingSentToAddress.toString(), ); const slpData = slpSendObj.script; // Add OP_RETURN as first output. transactionBuilder.addOutput(slpData, 0); // Send dust transaction representing tokens being sent. transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress), currency.etokenSats, ); // Return any token change back to the sender. if (slpSendObj.outputs > 1) { // Change goes back to where slp utxo came from transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress( tokenUtxosBeingSpent[0].address, ), currency.etokenSats, ); } // get byte count to calculate fee. paying 1 sat // Note: This may not be totally accurate. Just guessing on the byteCount size. const txFee = calcFee( BCH, tokenUtxosBeingSpent, 5, 1.1 * currency.defaultFee, ); // amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size const remainder = originalAmount - txFee - currency.etokenSats * 2; if (remainder < 1) { throw new Error('Selected UTXO does not have enough satoshis'); } // Last output: send the BCH change back to the wallet. // Send it back from whence it came transactionBuilder.addOutput( BCH.Address.toLegacyAddress(largestBchUtxo.address), remainder, ); // Sign the transaction with the private key for the BCH UTXO paying the fees. let redeemScript; transactionBuilder.sign( 0, bchECPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, originalAmount, ); // Sign each token UTXO being consumed. for (let i = 0; i < tokenUtxosBeingSpent.length; i++) { const thisUtxo = tokenUtxosBeingSpent[i]; const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const utxoEcPair = BCH.ECPair.fromWIF( accounts .filter(acc => acc.cashAddress === thisUtxo.address) .pop().fundingWif, ); transactionBuilder.sign( 1 + i, utxoEcPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, thisUtxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // console.log(`Transaction raw hex: `, hex); // END transaction construction. const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.tokenTicker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; }; + const burnEtoken = async ( + BCH, + wallet, + slpBalancesAndUtxos, + { tokenId, amount }, + ) => { + // Handle error of user having no XEC + if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { + throw new Error(`You need some ${currency.ticker} to burn eTokens`); + } + const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( + (previous, current) => + previous.value > current.value ? previous : current, + ); + + const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); + const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter(utxo => { + if ( + utxo && // UTXO is associated with a token. + utxo.tokenId === tokenId && // UTXO matches the token ID. + utxo.utxoType === 'token' // UTXO is not a minting baton. + ) { + return true; + } + return false; + }); + + if (tokenUtxos.length === 0) { + throw new Error( + 'No token UTXOs for the specified token could be found.', + ); + } + + // BEGIN transaction construction. + + // instance of transaction builder + let transactionBuilder; + if (process.env.REACT_APP_NETWORK === 'mainnet') { + transactionBuilder = new BCH.TransactionBuilder(); + } else transactionBuilder = new BCH.TransactionBuilder('testnet'); + + const originalAmount = largestBchUtxo.value; + transactionBuilder.addInput( + largestBchUtxo.tx_hash, + largestBchUtxo.tx_pos, + ); + + let finalTokenAmountBurnt = new BigNumber(0); + let tokenAmountBeingBurnt = new BigNumber(amount); + + let tokenUtxosBeingBurnt = []; + for (let i = 0; i < tokenUtxos.length; i++) { + finalTokenAmountBurnt = finalTokenAmountBurnt.plus( + new BigNumber(tokenUtxos[i].tokenQty), + ); + transactionBuilder.addInput( + tokenUtxos[i].tx_hash, + tokenUtxos[i].tx_pos, + ); + tokenUtxosBeingBurnt.push(tokenUtxos[i]); + if (tokenAmountBeingBurnt.lte(finalTokenAmountBurnt)) { + break; + } + } + + const slpBurnObj = BCH.SLP.TokenType1.generateBurnOpReturn( + tokenUtxosBeingBurnt, + tokenAmountBeingBurnt, + ); + + if (!slpBurnObj) { + throw new Error(`Invalid eToken burn transaction.`); + } + + // Add OP_RETURN as first output. + transactionBuilder.addOutput(slpBurnObj, 0); + + // Send dust transaction representing tokens being burnt. + transactionBuilder.addOutput( + BCH.SLP.Address.toLegacyAddress(largestBchUtxo.address), + currency.etokenSats, + ); + + // get byte count to calculate fee. paying 1 sat + const txFee = calcFee( + BCH, + tokenUtxosBeingBurnt, + 3, + currency.defaultFee, + ); + + // amount to send back to the address requesting the burn. It's the original amount - 1 sat/byte for tx size + const remainder = originalAmount - txFee - currency.etokenSats * 2; + if (remainder < 1) { + throw new Error('Selected UTXO does not have enough satoshis'); + } + + // Send it back from whence it came + transactionBuilder.addOutput( + BCH.Address.toLegacyAddress(largestBchUtxo.address), + remainder, + ); + + // Sign the transaction with the private key for the XEC UTXO paying the fees. + let redeemScript; + transactionBuilder.sign( + 0, + bchECPair, + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + originalAmount, + ); + + // Sign each token UTXO being consumed. + for (let i = 0; i < tokenUtxosBeingBurnt.length; i++) { + const thisUtxo = tokenUtxosBeingBurnt[i]; + const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; + const utxoEcPair = BCH.ECPair.fromWIF( + accounts + .filter(acc => acc.cashAddress === thisUtxo.address) + .pop().fundingWif, + ); + + transactionBuilder.sign( + 1 + i, + utxoEcPair, + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + thisUtxo.value, + ); + } + + // build tx + const tx = transactionBuilder.build(); + + // output rawhex + const hex = tx.toHex(); + // console.log(`Transaction raw hex: `, hex); + + // END transaction construction. + + const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); + if (txidStr && txidStr[0]) { + console.log(`${currency.tokenTicker} txid`, txidStr[0]); + } + + let link; + if (process.env.REACT_APP_NETWORK === `mainnet`) { + link = `${currency.tokenExplorerUrl}/tx/${txidStr}`; + } else { + link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; + } + + return link; + }; + const signPkMessage = async (BCH, pk, message) => { try { let signature = await BCH.BitcoinCash.signMessageWithPrivKey( pk, message, ); return signature; } catch (err) { console.log(`useBCH.signPkMessage() error: `, err); throw err; } }; const getRecipientPublicKey = async (BCH, recipientAddress) => { let recipientPubKey; try { recipientPubKey = await getPublicKey(BCH, recipientAddress); } catch (err) { console.log(`useBCH.getRecipientPublicKey() error: ` + err); throw err; } return recipientPubKey; }; const handleEncryptedOpReturn = async ( BCH, destinationAddress, optionalOpReturnMsg, ) => { let recipientPubKey, encryptedEj; try { recipientPubKey = await getRecipientPublicKey( BCH, destinationAddress, ); } catch (err) { console.log(`useBCH.handleEncryptedOpReturn() error: ` + err); throw err; } if (recipientPubKey === 'not found') { // if the API can't find a pub key, it is due to the wallet having no outbound tx throw new Error( 'Cannot send an encrypted message to a wallet with no outgoing transactions', ); } try { const pubKeyBuf = Buffer.from(recipientPubKey, 'hex'); const bufferedFile = Buffer.from(optionalOpReturnMsg); const structuredEj = await ecies.encrypt(pubKeyBuf, bufferedFile); // Serialize the encrypted data object encryptedEj = Buffer.concat([ structuredEj.epk, structuredEj.iv, structuredEj.ct, structuredEj.mac, ]); } catch (err) { console.log(`useBCH.handleEncryptedOpReturn() error: ` + err); throw err; } return encryptedEj; }; const sendXec = async ( BCH, wallet, utxos, feeInSatsPerByte, optionalOpReturnMsg, isOneToMany, destinationAddressAndValueArray, destinationAddress, sendAmount, encryptionFlag, ) => { try { let value = new BigNumber(0); if (isOneToMany) { // this is a one to many XEC transaction if ( !destinationAddressAndValueArray || !destinationAddressAndValueArray.length ) { throw new Error('Invalid destinationAddressAndValueArray'); } const arrayLength = destinationAddressAndValueArray.length; for (let i = 0; i < arrayLength; i++) { // add the total value being sent in this array of recipients value = BigNumber.sum( value, new BigNumber( destinationAddressAndValueArray[i].split(',')[1], ), ); } // If user is attempting to send an aggregate value that is less than minimum accepted by the backend if ( value.lt( new BigNumber( fromSmallestDenomination( currency.dustSats, ).toString(), ), ) ) { // Throw the same error given by the backend attempting to broadcast such a tx throw new Error('dust'); } } else { // this is a one to one XEC transaction then check sendAmount // note: one to many transactions won't be sending a single sendAmount if (!sendAmount) { return null; } value = new BigNumber(sendAmount); // If user is attempting to send less than minimum accepted by the backend if ( value.lt( new BigNumber( fromSmallestDenomination( currency.dustSats, ).toString(), ), ) ) { // Throw the same error given by the backend attempting to broadcast such a tx throw new Error('dust'); } } const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); const satoshisToSend = toSmallestDenomination(value); // Throw validation error if toSmallestDenomination returns false if (!satoshisToSend) { const error = new Error( `Invalid decimal places for send amount`, ); throw error; } let script; // Start of building the OP_RETURN output. // only build the OP_RETURN output if the user supplied it if ( optionalOpReturnMsg && typeof optionalOpReturnMsg !== 'undefined' && optionalOpReturnMsg.trim() !== '' ) { if (encryptionFlag) { // if the user has opted to encrypt this message let encryptedEj; try { encryptedEj = await handleEncryptedOpReturn( BCH, destinationAddress, optionalOpReturnMsg, ); } catch (err) { console.log(`useBCH.sendXec() encryption error.`); throw err; } // build the OP_RETURN script with the encryption prefix script = [ BCH.Script.opcodes.OP_RETURN, // 6a Buffer.from( currency.opReturn.appPrefixesHex.cashtabEncrypted, 'hex', ), // 65746162 Buffer.from(encryptedEj), ]; } else { // this is an un-encrypted message script = [ BCH.Script.opcodes.OP_RETURN, // 6a Buffer.from( currency.opReturn.appPrefixesHex.cashtab, 'hex', ), // 00746162 Buffer.from(optionalOpReturnMsg), ]; } const data = BCH.Script.encode(script); transactionBuilder.addOutput(data, 0); } // End of building the OP_RETURN output. let originalAmount = new BigNumber(0); let txFee = 0; // 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]; originalAmount = originalAmount.plus(utxo.value); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(BCH, inputUtxos, txOutputs, feeInSatsPerByte); if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { break; } } // Get change address from sending utxos // fall back to what is stored in wallet let REMAINDER_ADDR; // Validate address let isValidChangeAddress; try { REMAINDER_ADDR = inputUtxos[0].address; isValidChangeAddress = BCH.Address.isCashAddress(REMAINDER_ADDR); } catch (err) { isValidChangeAddress = false; } if (!isValidChangeAddress) { REMAINDER_ADDR = wallet.Path1899.cashAddress; } // amount to send back to the remainder address. const remainder = originalAmount.minus(satoshisToSend).minus(txFee); if (remainder.lt(0)) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; throw error; } if (isOneToMany) { // for one to many mode, add the multiple outputs from the array let arrayLength = destinationAddressAndValueArray.length; for (let i = 0; i < arrayLength; i++) { // add each send tx from the array as an output let outputAddress = destinationAddressAndValueArray[i].split(',')[0]; let outputValue = new BigNumber( destinationAddressAndValueArray[i].split(',')[1], ); transactionBuilder.addOutput( BCH.Address.toCashAddress(outputAddress), parseInt(toSmallestDenomination(outputValue)), ); } } else { // for one to one mode, add output w/ single address and amount to send transactionBuilder.addOutput( BCH.Address.toCashAddress(destinationAddress), parseInt(toSmallestDenomination(value)), ); } if (remainder.gte(new BigNumber(currency.dustSats))) { transactionBuilder.addOutput( REMAINDER_ADDR, parseInt(remainder), ); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; const getBCH = (apiIndex = 0) => { let ConstructedSlpWallet; ConstructedSlpWallet = new SlpWallet('', { restURL: getRestUrl(apiIndex), }); return ConstructedSlpWallet.bchjs; }; return { getBCH, calcFee, getUtxos, getHydratedUtxoDetails, getSlpBalancesAndUtxos, getTxHistory, flattenTransactions, parseTxData, addTokenTxData, parseTokenInfoForTxHistory, getTxData, getRestUrl, signPkMessage, sendXec, sendToken, createToken, getTokenStats, handleEncryptedOpReturn, getRecipientPublicKey, + burnEtoken, }; } diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js index 462262c03..225e366e4 100644 --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -1,444 +1,477 @@ import { shouldRejectAmountInput, fiatToCrypto, isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, isValidTokenStats, isValidCashtabSettings, isValidXecAddress, isValidEtokenAddress, isValidXecSendAmount, isValidSendToMany, isValidUtxo, isValidBchApiUtxoObject, + isValidEtokenBurnAmount, } from '../validation'; import { currency } from '@components/Common/Ticker.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; import { stStatsValid, noCovidStatsValid, noCovidStatsInvalid, cGenStatsValid, } from '../__mocks__/mockTokenStats'; import { validUtxo, invalidUtxoMissingHeight, invalidUtxoTxidUndefined, hydratedUtxoDetailsAfterRemovingConsumedUtxos, utxosAfterSentTxIncremental, } from '../__mocks__/incrementalUtxoMocks'; describe('Validation utils', () => { it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount`, () => { expect(shouldRejectAmountInput('10', currency.ticker, 20.0, 300)).toBe( false, ); }); it(`Returns 'false' if ${currency.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 ${currency.ticker} send amount is not a number`, () => { const expectedValidationError = `Amount must be a number`; expect( shouldRejectAmountInput('Not a number', currency.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect(shouldRejectAmountInput('0', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is less than 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect( shouldRejectAmountInput('-0.031', currency.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSmallestDenomination( currency.dustSats, ).toString()} minimum`, () => { const expectedValidationError = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput( ( fromSmallestDenomination(currency.dustSats).toString() - 0.00000001 ).toString(), currency.ticker, 20.0, 3, ), ).toBe(expectedValidationError); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSmallestDenomination( currency.dustSats, ).toString()} minimum in fiat currency`, () => { const expectedValidationError = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput('0.0000005', 'USD', 0.00005, 1000000), ).toBe(expectedValidationError); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance with fiat currency selected`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.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 ${currency.ticker} send amount has more than ${currency.cashDecimals} decimal places`, () => { const expectedValidationError = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`; expect( shouldRejectAmountInput('17.123456789', currency.ticker, 20.0, 35), ).toBe(expectedValidationError); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.3231342349234234, 8)).toBe( '0.53989295', ); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.3231342349234234, 2)).toBe( '0.54', ); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have lower precision`, () => { expect(fiatToCrypto('10.94', 10, 8)).toBe('1.09400000'); }); it(`Accepts a valid ${currency.tokenTicker} token name`, () => { expect(isValidTokenName('Valid token name')).toBe(true); }); it(`Accepts a valid ${currency.tokenTicker} token name that is a stringified number`, () => { expect(isValidTokenName('123456789')).toBe(true); }); it(`Rejects ${currency.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 ${currency.tokenTicker} token name if empty string`, () => { expect(isValidTokenName('')).toBe(false); }); it(`Accepts a 4-char ${currency.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('DOGE')).toBe(true); }); it(`Accepts a 12-char ${currency.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('123456789123')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token ticker if empty string`, () => { expect(isValidTokenTicker('')).toBe(false); }); it(`Rejects ${currency.tokenTicker} token ticker if > 12 chars`, () => { expect(isValidTokenTicker('1234567891234')).toBe(false); }); it(`Accepts ${currency.tokenDecimals} if zero`, () => { expect(isValidTokenDecimals('0')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} if between 0 and 9 inclusive`, () => { expect(isValidTokenDecimals('9')).toBe(true); }); it(`Rejects ${currency.tokenDecimals} if empty string`, () => { expect(isValidTokenDecimals('')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} if non-integer`, () => { expect(isValidTokenDecimals('1.7')).toBe(false); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 3 decimal places`, () => { expect(isValidTokenInitialQty('0.001', '3')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 9 decimal places`, () => { expect(isValidTokenInitialQty('0.000000001', '9')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('1000', '0')).toBe(true); }); it(`Accepts highest possible ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('99999999999.999999999', '9')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places equal tokenDecimals`, () => { expect(isValidTokenInitialQty('0.123', '3')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places are less than tokenDecimals`, () => { expect(isValidTokenInitialQty('0.12345', '9')).toBe(true); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity of zero`, () => { expect(isValidTokenInitialQty('0', '9')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if tokenDecimals is not valid`, () => { expect(isValidTokenInitialQty('0', '')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if 100 billion or higher`, () => { expect(isValidTokenInitialQty('100000000000', '0')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if it has more decimal places than tokenDecimals`, () => { expect(isValidTokenInitialQty('1.5', '0')).toBe(false); }); it(`Accepts a valid ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('cashtabapp.com')).toBe(true); }); it(`Accepts a valid ${currency.tokenTicker} token document URL including special URL characters`, () => { expect(isValidTokenDocumentUrl('https://cashtabapp.com/')).toBe(true); }); it(`Accepts a blank string as a valid ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('')).toBe(true); }); it(`Rejects ${currency.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 ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('https://google.com')).toBe(true); }); it(`Accepts a domain input with http protocol as ${currency.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 ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('http://test.co.uk')).toBe(true); }); it(`Accepts a domain input with just a subdomain as ${currency.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 ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('mywebsite')).toBe(false); }); it(`Rejects a domain input as numbers ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl(12345)).toBe(false); }); it(`Correctly validates token stats for token created before the ${currency.ticker} fork`, () => { expect(isValidTokenStats(stStatsValid)).toBe(true); }); it(`Correctly validates token stats for token created after the ${currency.ticker} fork`, () => { expect(isValidTokenStats(noCovidStatsValid)).toBe(true); }); it(`Correctly validates token stats for token with no minting baton`, () => { expect(isValidTokenStats(cGenStatsValid)).toBe(true); }); it(`Recognizes a token stats object with missing required keys as invalid`, () => { expect(isValidTokenStats(noCovidStatsInvalid)).toBe(false); }); it(`Recognizes a valid cashtab settings object`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'usd', sendModal: 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(`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 = fromSmallestDenomination(currency.dustSats); expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount accepts arbitrary number above dust minimum`, () => { const testXecSendAmount = fromSmallestDenomination(currency.dustSats) + 1.75; expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount rejects zero`, () => { const testXecSendAmount = 0; expect(isValidXecSendAmount(testXecSendAmount)).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 = `${ fromSmallestDenomination(currency.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(`isValidUtxo returns true for a valid utxo`, () => { expect(isValidUtxo(validUtxo)).toBe(true); }); it(`isValidUtxo returns false for missing height`, () => { expect(isValidUtxo(invalidUtxoMissingHeight)).toBe(false); }); it(`isValidUtxo returns false for undefined tx_hash`, () => { expect(isValidUtxo(invalidUtxoTxidUndefined)).toBe(false); }); it(`isValidUtxo returns false for null`, () => { expect(isValidUtxo(null)).toBe(false); }); it(`isValidUtxo returns false for undefined`, () => { expect(isValidUtxo()).toBe(false); }); it(`isValidUtxo returns false for empty object`, () => { expect(isValidUtxo({})).toBe(false); }); it(`isValidBchApiUtxoObject returns false for object`, () => { expect(isValidBchApiUtxoObject({})).toBe(false); }); it(`isValidBchApiUtxoObject returns false for empty array`, () => { expect(isValidBchApiUtxoObject([])).toBe(false); }); it(`isValidBchApiUtxoObject returns false for null`, () => { expect(isValidBchApiUtxoObject(null)).toBe(false); }); it(`isValidBchApiUtxoObject returns false for undefined`, () => { expect(isValidBchApiUtxoObject(undefined)).toBe(false); }); it(`isValidBchApiUtxoObject returns false for hydratedUtxoDetails type object`, () => { expect( isValidBchApiUtxoObject( hydratedUtxoDetailsAfterRemovingConsumedUtxos, ), ).toBe(false); }); it(`isValidBchApiUtxoObject returns true for hydratedUtxoDetails.slpUtxos`, () => { expect( isValidBchApiUtxoObject( hydratedUtxoDetailsAfterRemovingConsumedUtxos.slpUtxos, ), ).toBe(true); }); it(`isValidBchApiUtxoObject returns true for valid bch-api utxos object`, () => { expect(isValidBchApiUtxoObject(utxosAfterSentTxIncremental)).toBe(true); }); + 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); + }); }); diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js index 75707b132..3a9e2ac0b 100644 --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -1,306 +1,318 @@ import BigNumber from 'bignumber.js'; import { currency } from '@components/Common/Ticker.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; // Validate cash amount export const shouldRejectAmountInput = ( cashAmount, selectedCurrency, fiatPrice, totalCashBalance, ) => { // Take cashAmount as input, a string from form input let error = false; let testedAmount = new BigNumber(cashAmount); if (selectedCurrency !== currency.ticker) { // Ensure no more than currency.cashDecimals decimal places testedAmount = new BigNumber(fiatToCrypto(cashAmount, fiatPrice)); } // Validate value for > 0 if (isNaN(testedAmount)) { error = 'Amount must be a number'; } else if (testedAmount.lte(0)) { error = 'Amount must be greater than 0'; } else if ( testedAmount.lt(fromSmallestDenomination(currency.dustSats).toString()) ) { error = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; } else if (testedAmount.gt(totalCashBalance)) { error = `Amount cannot exceed your ${currency.ticker} balance`; } else if (!isNaN(testedAmount) && testedAmount.toString().includes('.')) { if ( testedAmount.toString().split('.')[1].length > currency.cashDecimals ) { error = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`; } } // return false if no error, or string error msg if error return error; }; export const fiatToCrypto = ( fiatAmount, fiatPrice, cashDecimals = currency.cashDecimals, ) => { let cryptoAmount = new BigNumber(fiatAmount) .div(new BigNumber(fiatPrice)) .toFixed(cashDecimals); return cryptoAmount; }; export const isValidTokenName = tokenName => { return ( typeof tokenName === 'string' && tokenName.length > 0 && tokenName.length < 68 ); }; export const isValidTokenTicker = tokenTicker => { return ( typeof tokenTicker === 'string' && tokenTicker.length > 0 && tokenTicker.length < 13 ); }; export const isValidTokenDecimals = tokenDecimals => { return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes( tokenDecimals, ); }; export const isValidTokenInitialQty = (tokenInitialQty, tokenDecimals) => { const minimumQty = new BigNumber(1 / 10 ** tokenDecimals); const tokenIntialQtyBig = new BigNumber(tokenInitialQty); return ( tokenIntialQtyBig.gte(minimumQty) && tokenIntialQtyBig.lt(100000000000) && tokenIntialQtyBig.dp() <= tokenDecimals ); }; export const isValidTokenDocumentUrl = tokenDocumentUrl => { const urlPattern = new RegExp( '^(https?:\\/\\/)?' + // protocol '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string '(\\#[-a-z\\d_]*)?$', 'i', ); // fragment locator const urlTestResult = urlPattern.test(tokenDocumentUrl); return ( tokenDocumentUrl === '' || (typeof tokenDocumentUrl === 'string' && tokenDocumentUrl.length >= 0 && tokenDocumentUrl.length < 68 && urlTestResult) ); }; export const isValidTokenStats = tokenStats => { return ( typeof tokenStats === 'object' && 'timestampUnix' in tokenStats && 'documentUri' in tokenStats && 'containsBaton' in tokenStats && 'initialTokenQty' in tokenStats && 'totalMinted' in tokenStats && 'totalBurned' in tokenStats && 'circulatingSupply' in tokenStats ); }; export const isValidCashtabSettings = settings => { try { let isValidSettingParams = true; for (let param in currency.defaultSettings) { if ( !Object.prototype.hasOwnProperty.call(settings, param) || !currency.settingsValidation[param].includes(settings[param]) ) { isValidSettingParams = false; break; } } const isValid = typeof settings === 'object' && isValidSettingParams; return isValid; } catch (err) { return false; } }; export const isValidXecAddress = addr => { /* Returns true for a valid XEC address Valid XEC address: - May or may not have prefix `ecash:` - Checksum must validate for prefix `ecash:` An eToken address is not considered a valid XEC address */ if (!addr) { return false; } let isValidXecAddress; let isPrefixedXecAddress; // Check for possible prefix if (addr.includes(':')) { // Test for 'ecash:' prefix isPrefixedXecAddress = addr.slice(0, 6) === 'ecash:'; // Any address including ':' that doesn't start explicitly with 'ecash:' is invalid if (!isPrefixedXecAddress) { isValidXecAddress = false; return isValidXecAddress; } } else { isPrefixedXecAddress = false; } // If no prefix, assume it is checksummed for an ecash: prefix const testedXecAddr = isPrefixedXecAddress ? addr : `ecash:${addr}`; try { const decoded = cashaddr.decode(testedXecAddr); if (decoded.prefix === 'ecash') { isValidXecAddress = true; } } catch (err) { isValidXecAddress = false; } return isValidXecAddress; }; export const isValidEtokenAddress = addr => { /* Returns true for a valid eToken address Valid eToken address: - May or may not have prefix `etoken:` - Checksum must validate for prefix `etoken:` An XEC address is not considered a valid eToken address */ if (!addr) { return false; } let isValidEtokenAddress; let isPrefixedEtokenAddress; // Check for possible prefix if (addr.includes(':')) { // Test for 'etoken:' prefix isPrefixedEtokenAddress = addr.slice(0, 7) === 'etoken:'; // Any token address including ':' that doesn't start explicitly with 'etoken:' is invalid if (!isPrefixedEtokenAddress) { isValidEtokenAddress = false; return isValidEtokenAddress; } } else { isPrefixedEtokenAddress = false; } // If no prefix, assume it is checksummed for an etoken: prefix const testedEtokenAddr = isPrefixedEtokenAddress ? addr : `etoken:${addr}`; try { const decoded = cashaddr.decode(testedEtokenAddr); if (decoded.prefix === 'etoken') { isValidEtokenAddress = true; } } catch (err) { isValidEtokenAddress = false; } return isValidEtokenAddress; }; export const isValidXecSendAmount = xecSendAmount => { // A valid XEC send amount must be a number higher than the app dust limit return ( xecSendAmount !== null && typeof xecSendAmount !== 'undefined' && !isNaN(parseFloat(xecSendAmount)) && parseFloat(xecSendAmount) >= fromSmallestDenomination(currency.dustSats) ); }; export const isValidUtxo = utxo => { let isValidUtxo = false; try { isValidUtxo = 'height' in utxo && typeof utxo.height === 'number' && 'tx_hash' in utxo && typeof utxo.tx_hash === 'string' && 'tx_pos' in utxo && typeof utxo.tx_pos === 'number' && 'value' in utxo && typeof utxo.value === 'number'; } catch (err) { return false; } return isValidUtxo; }; export const isValidBchApiUtxoObject = bchApiUtxoObject => { /* [ { address: 'string', utxos: [{}, {}, {}...{}] }, { address: 'string', utxos: [{}, {}, {}...{}] }, { address: 'string', utxos: [{}, {}, {}...{}] }, ] */ let isValidBchApiUtxoObject = false; // Must be an array if (!Array.isArray(bchApiUtxoObject)) { return isValidBchApiUtxoObject; } // Do not accept an empty array if (bchApiUtxoObject.length < 1) { return isValidBchApiUtxoObject; } for (let i = 0; i < bchApiUtxoObject.length; i += 1) { let thisUtxoObject = bchApiUtxoObject[i]; if ('address' in thisUtxoObject && 'utxos' in thisUtxoObject) { const thisUtxoArray = thisUtxoObject.utxos; if (Array.isArray(thisUtxoArray)) { // do not validate each individual utxo in the array // we are only validating the object structure here continue; } else { return isValidBchApiUtxoObject; } } else { return isValidBchApiUtxoObject; } } isValidBchApiUtxoObject = true; return isValidBchApiUtxoObject; }; + +export const isValidEtokenBurnAmount = (tokenBurnAmount, maxAmount) => { + // A valid eToken burn amount must be between 1 and the wallet's token balance + return ( + tokenBurnAmount !== null && + maxAmount !== null && + typeof tokenBurnAmount !== 'undefined' && + typeof maxAmount !== 'undefined' && + new BigNumber(tokenBurnAmount).gt(0) && + new BigNumber(tokenBurnAmount).lte(maxAmount) + ); +};