diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml new file mode 100644 index 000000000..3b84416ef --- /dev/null +++ b/.github/workflows/create-release-pr.yml @@ -0,0 +1,39 @@ +name: Create release PR + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version bump level' + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +jobs: + create-release-pr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + - id: run-create-release-pr-sh + env: + BUMP: ${{ inputs.bump }} + GH_TOKEN: ${{ github.token }} + run: | + # git config + git config user.name "GitHub Actions Bot" + git config user.email "<>" + # run shell script + cd scripts + ./create-release-pr.sh diff --git a/package-lock.json b/package-lock.json index 5e636435d..4e3dc1833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "xverse-web-extension", - "version": "0.25.0", + "version": "0.26.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "xverse-web-extension", - "version": "0.25.0", + "version": "0.26.0", "dependencies": { "@ledgerhq/hw-transport-webusb": "^6.27.13", "@phosphor-icons/react": "^2.0.10", "@react-spring/web": "^9.6.1", - "@secretkeylabs/xverse-core": "5.0.0", + "@secretkeylabs/xverse-core": "5.2.0", "@stacks/connect": "^6.10.2", "@stacks/encryption": "4.3.5", "@stacks/stacks-blockchain-api-types": "6.1.1", @@ -1727,9 +1727,9 @@ } }, "node_modules/@secretkeylabs/xverse-core": { - "version": "5.0.0", - "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/5.0.0/8e902ec524e53dc55b447a38881dd4e460a9beb5", - "integrity": "sha512-CuTAvCrPRsn9CQQrhoKeioqvjC+HgYXRFJD7DFAX00cN2I6PNwS5kQDczLqoe1pAPBdszXt/i5c6zPH3dUI5+A==", + "version": "5.2.0", + "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/5.2.0/f4d46eacac2b7be0b6fb70ce5184d1c10ed7bfa6", + "integrity": "sha512-DhDrTAFYPHf22ThYn+z9DIlxXN6M7jH5JEbAi5I58xE62RsEOcpxfRHDhfNzcNAyWKJEXzChkbOp7OpFHLIF1w==", "license": "ISC", "dependencies": { "@bitcoinerlab/secp256k1": "^1.0.2", @@ -16046,9 +16046,9 @@ } }, "@secretkeylabs/xverse-core": { - "version": "5.0.0", - "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/5.0.0/8e902ec524e53dc55b447a38881dd4e460a9beb5", - "integrity": "sha512-CuTAvCrPRsn9CQQrhoKeioqvjC+HgYXRFJD7DFAX00cN2I6PNwS5kQDczLqoe1pAPBdszXt/i5c6zPH3dUI5+A==", + "version": "5.2.0", + "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/5.2.0/f4d46eacac2b7be0b6fb70ce5184d1c10ed7bfa6", + "integrity": "sha512-DhDrTAFYPHf22ThYn+z9DIlxXN6M7jH5JEbAi5I58xE62RsEOcpxfRHDhfNzcNAyWKJEXzChkbOp7OpFHLIF1w==", "requires": { "@bitcoinerlab/secp256k1": "^1.0.2", "@noble/secp256k1": "^1.7.1", diff --git a/package.json b/package.json index 0d158b38b..c50262364 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xverse-web-extension", "description": "A Bitcoin wallet for Web3", - "version": "0.25.0", + "version": "0.26.0", "private": true, "engines": { "node": "^18.18.2" @@ -10,7 +10,7 @@ "@ledgerhq/hw-transport-webusb": "^6.27.13", "@phosphor-icons/react": "^2.0.10", "@react-spring/web": "^9.6.1", - "@secretkeylabs/xverse-core": "5.0.0", + "@secretkeylabs/xverse-core": "5.2.0", "@stacks/connect": "^6.10.2", "@stacks/encryption": "4.3.5", "@stacks/stacks-blockchain-api-types": "6.1.1", diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 000000000..62c82f717 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,3 @@ +release.json +pr-*.json +body.md diff --git a/scripts/create-release-pr.sh b/scripts/create-release-pr.sh new file mode 100755 index 000000000..84e3721d7 --- /dev/null +++ b/scripts/create-release-pr.sh @@ -0,0 +1,74 @@ +#! /bin/bash + +if [[ -z "$BUMP" ]]; then + echo "BUMP is required. major|minor|patch" + exit 1 +fi + +echo -e "\n--- Prepare for $BUMP release branch ---" + +git fetch --all +git checkout develop +git pull + +npm version $BUMP --git-tag-version=false +VERSION=$(npm pkg get version | sed 's/"//g') +TAG="v$VERSION" +BRANCH="release/$TAG" +TITLE="release: $TAG" + +git checkout -B $BRANCH +git commit -am "$TITLE" +git merge origin/main -s ours + +git push --set-upstream origin $BRANCH + +echo -e "\n--- Create draft release for $TAG ---" + +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/{owner}/{repo}/releases \ + -f tag_name=$TAG \ + -f target_commitish="$BRANCH" \ + -f name=$TAG \ + -F draft=true \ + -F prerelease=true \ + -F generate_release_notes=true > release.json + +cat release.json | jq -r .body > body.md +echo -e "\n\nDraft release: $(cat release.json | jq -r .html_url)" >> body.md + +for b in main develop; do + echo -e "\n--- Create PR to $b ---" + + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/{owner}/{repo}/pulls \ + -f title="$TITLE to $b" \ + -f body="Created by GitHub Actions Bot" \ + -f head="$BRANCH" \ + -f base="$b" > pr-$b.json + + echo -e "\n--- Update PR to $b with description ---" + + PR_ID=$(cat pr-$b.json | jq -r .number) + + gh api \ + --method PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/{owner}/{repo}/pulls/$PR_ID \ + -F 'body=@body.md' + + # clean up temp files + # rm pr-$b.json +done + +echo -e "\n--- Done ---" +# clean up temp files +# rm release.json +# rm body.md diff --git a/src/app/App.tsx b/src/app/App.tsx index 73c0f2138..c11c784c1 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,5 @@ import LoadingScreen from '@components/loadingScreen'; +import { CheckCircle } from '@phosphor-icons/react'; import rootStore from '@stores/index'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -40,7 +41,23 @@ function App(): JSX.Element { - + , + style: { + ...Theme.typography.body_medium_m, + backgroundColor: Theme.colors.success_medium, + borderRadius: Theme.radius(2), + padding: Theme.spacing(4), + paddingLeft: Theme.spacing(6), + color: Theme.colors.elevation0, + }, + }, + }} + /> diff --git a/src/app/components/bottomModal/index.tsx b/src/app/components/bottomModal/index.tsx index 24a431f4b..959444c61 100644 --- a/src/app/components/bottomModal/index.tsx +++ b/src/app/components/bottomModal/index.tsx @@ -4,7 +4,7 @@ import Modal from 'react-modal'; import styled, { useTheme } from 'styled-components'; const BottomModalHeaderText = styled.h1((props) => ({ - ...props.theme.body_bold_m, + ...props.theme.typography.body_bold_l, flex: 1, })); diff --git a/src/app/components/button/index.tsx b/src/app/components/button/index.tsx index c33a6f5d5..6e7ddaa2b 100644 --- a/src/app/components/button/index.tsx +++ b/src/app/components/button/index.tsx @@ -81,7 +81,7 @@ interface Props { icon?: JSX.Element; iconPosition?: 'left' | 'right'; text: string; - onPress: () => void; + onPress: (e: React.MouseEvent) => void; processing?: boolean; disabled?: boolean; transparent?: boolean; @@ -102,9 +102,9 @@ function ActionButton({ warning, hoverDialogId, }: Props) { - const handleOnPress = () => { + const handleOnPress = (e: React.MouseEvent) => { if (!disabled) { - onPress(); + onPress(e); } }; diff --git a/src/app/components/confirmBtcTransactionComponent/index.tsx b/src/app/components/confirmBtcTransactionComponent/index.tsx index 7cafa1915..290f3e4ed 100644 --- a/src/app/components/confirmBtcTransactionComponent/index.tsx +++ b/src/app/components/confirmBtcTransactionComponent/index.tsx @@ -6,6 +6,7 @@ import RecipientComponent from '@components/recipientComponent'; import TransactionSettingAlert from '@components/transactionSetting'; import TransferFeeView from '@components/transferFeeView'; import useNftDataSelector from '@hooks/stores/useNftDataSelector'; +import useBtcClient from '@hooks/useBtcClient'; import useOrdinalsByAddress from '@hooks/useOrdinalsByAddress'; import useSeedVault from '@hooks/useSeedVault'; import useWalletSelector from '@hooks/useWalletSelector'; @@ -148,6 +149,7 @@ function ConfirmBtcTransactionComponent({ const [signedTx, setSignedTx] = useState(signedTxHex); const [total, setTotal] = useState(new BigNumber(0)); const [showFeeWarning, setShowFeeWarning] = useState(false); + const btcClient = useBtcClient(); const bundle = selectedSatBundle ?? ordinalBundle ?? undefined; const { @@ -170,6 +172,7 @@ function ConfirmBtcTransactionComponent({ btcAddress, selectedAccount?.id ?? 0, seedPhrase, + btcClient, network.type, new BigNumber(txFee), ), @@ -221,6 +224,7 @@ function ConfirmBtcTransactionComponent({ btcAddress, Number(selectedAccount?.id), seedPhrase, + btcClient, network.type, ordinalsUtxos, new BigNumber(txFee), diff --git a/src/app/components/transactionSetting/editFee.tsx b/src/app/components/transactionSetting/editFee.tsx index 57062d8b7..c58c7fbe5 100644 --- a/src/app/components/transactionSetting/editFee.tsx +++ b/src/app/components/transactionSetting/editFee.tsx @@ -1,3 +1,4 @@ +import useBtcClient from '@hooks/useBtcClient'; import useDebounce from '@hooks/useDebounce'; import useOrdinalsByAddress from '@hooks/useOrdinalsByAddress'; import useWalletSelector from '@hooks/useWalletSelector'; @@ -57,10 +58,7 @@ const InputContainer = styled.div((props) => ({ }`, backgroundColor: props.theme.colors.elevation1, borderRadius: props.theme.radius(1), - paddingLeft: props.theme.spacing(5), - paddingRight: props.theme.spacing(5), - paddingTop: props.theme.spacing(5), - paddingBottom: props.theme.spacing(5), + padding: props.theme.spacing(5), })); const InputField = styled.input((props) => ({ @@ -102,7 +100,7 @@ const FeeButton = styled.button((props) => ({ color: `${props.isSelected ? props.theme.colors.elevation2 : props.theme.colors.white_400}`, background: `${props.isSelected ? props.theme.colors.white : 'transparent'}`, border: `1px solid ${props.isSelected ? 'transparent' : props.theme.colors.elevation6}`, - borderRadius: 40, + borderRadius: props.theme.radius(9), width: props.isBtc ? 104 : 82, height: 40, display: 'flex', @@ -111,13 +109,13 @@ const FeeButton = styled.button((props) => ({ marginRight: props.isLastInRow ? 0 : 8, })); -const ButtonContainer = styled.div({ +const ButtonContainer = styled.div((props) => ({ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - marginTop: 12, -}); + marginTop: props.theme.spacing(6), +})); const FeeContainer = styled.div({ display: 'flex', @@ -190,6 +188,7 @@ function EditFee({ const isStx = type === 'STX'; const { ordinals } = useOrdinalsByAddress(btcAddress); const ordinalsUtxos = useMemo(() => ordinals?.map((ord) => ord.utxo), [ordinals]); + const btcClient = useBtcClient(); const modifyStxFees = (mode: string) => { const currentFee = new BigNumber(fee); @@ -237,6 +236,7 @@ function EditFee({ const { fee: modifiedFee, selectedFeeRate } = await getBtcFees( btcRecipients, btcAddress, + btcClient, network.type, mode, ); @@ -248,6 +248,7 @@ function EditFee({ btcRecipients[0].address, ordinalTxUtxo, btcAddress, + btcClient, network.type, ordinalsUtxos || [], mode, @@ -297,6 +298,7 @@ function EditFee({ const { fee: modifiedFee, selectedFeeRate } = await getBtcFees( btcRecipients, btcAddress, + btcClient, network.type, feeMode, feeRateInput, @@ -322,6 +324,7 @@ function EditFee({ btcRecipients[0].address, ordinalTxUtxo, btcAddress, + btcClient, network.type, ordinalsUtxos || [], feeMode, diff --git a/src/app/components/transactionSetting/editNonce.tsx b/src/app/components/transactionSetting/editNonce.tsx index b51610ee9..54d30d1c9 100644 --- a/src/app/components/transactionSetting/editNonce.tsx +++ b/src/app/components/transactionSetting/editNonce.tsx @@ -51,6 +51,7 @@ interface Props { function EditNonce({ nonce, setNonce }: Props) { const { t } = useTranslation('translation', { keyPrefix: 'TRANSACTION_SETTING' }); const [nonceInput, setNonceInput] = useState(nonce); + const onInputEditNonceChange = (e: React.ChangeEvent) => { setNonceInput(e.target.value); }; diff --git a/src/app/components/transactions/btcTransaction.tsx b/src/app/components/transactions/btcTransaction.tsx index a22a1a9b5..40194084b 100644 --- a/src/app/components/transactions/btcTransaction.tsx +++ b/src/app/components/transactions/btcTransaction.tsx @@ -1,23 +1,28 @@ +import ActionButton from '@components/button'; import useWalletSelector from '@hooks/useWalletSelector'; -import { Brc20HistoryTransactionData, BtcTransactionData } from '@secretkeylabs/xverse-core'; +import { FastForward } from '@phosphor-icons/react'; +import { + Brc20HistoryTransactionData, + BtcTransactionData, + rbf, + RBFProps, +} from '@secretkeylabs/xverse-core'; import { getBtcTxStatusUrl } from '@utils/helper'; import { isBtcTransaction } from '@utils/transactions/transactions'; import { useCallback } from 'react'; -import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import styled, { useTheme } from 'styled-components'; import TransactionAmount from './transactionAmount'; import TransactionRecipient from './transactionRecipient'; import TransactionStatusIcon from './transactionStatusIcon'; import TransactionTitle from './transactionTitle'; -interface TransactionHistoryItemProps { - transaction: BtcTransactionData | Brc20HistoryTransactionData; -} - const TransactionContainer = styled.button((props) => ({ display: 'flex', + alignItems: 'center', width: '100%', - paddingTop: props.theme.spacing(5), - paddingBottom: props.theme.spacing(5), + padding: props.theme.spacing(5), paddingLeft: props.theme.spacing(8), paddingRight: props.theme.spacing(8), background: 'none', @@ -30,10 +35,11 @@ const TransactionContainer = styled.button((props) => ({ })); const TransactionAmountContainer = styled.div({ + width: '100%', display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', flex: 1, - width: '100%', - justifyContent: 'flex-end', }); const TransactionInfoContainer = styled.div((props) => ({ @@ -47,30 +53,74 @@ const TransactionRow = styled.div((props) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - ...props.theme.body_bold_m, + ...props.theme.typography.body_bold_m, +})); + +const StyledButton = styled(ActionButton)((props) => ({ + padding: 0, + border: 'none', + width: 'auto', + height: 'auto', + div: { + ...props.theme.typography.body_medium_m, + color: props.theme.colors.tangerine, + }, + ':hover:enabled': { + backgroundColor: 'transparent', + }, + ':active:enabled': { + backgroundColor: 'transparent', + }, })); -export default function BtcTransactionHistoryItem(props: TransactionHistoryItemProps) { - const { transaction } = props; - const { network } = useWalletSelector(); +interface TransactionHistoryItemProps { + transaction: BtcTransactionData | Brc20HistoryTransactionData; + wallet: RBFProps; +} +export default function BtcTransactionHistoryItem({ + transaction, + wallet, +}: TransactionHistoryItemProps) { + const { network, hasActivatedRBFKey } = useWalletSelector(); const isBtc = isBtcTransaction(transaction) ? 'BTC' : 'brc20'; + const theme = useTheme(); + const { t } = useTranslation('translation', { keyPrefix: 'COIN_DASHBOARD_SCREEN' }); + const openBtcTxStatusLink = useCallback(() => { window.open(getBtcTxStatusUrl(transaction.txid, network), '_blank', 'noopener,noreferrer'); }, []); + const showAccelerateButton = + hasActivatedRBFKey && + isBtcTransaction(transaction) && + rbf.isTransactionRbfEnabled(transaction, wallet); + return ( - +
+ + +
+ {showAccelerateButton && ( + + { + e.stopPropagation(); + }} + icon={} + iconPosition="right" + /> + + )}
- - -
); diff --git a/src/app/components/transactions/transactionTitle.tsx b/src/app/components/transactions/transactionTitle.tsx index 74f11df41..f19fded4c 100644 --- a/src/app/components/transactions/transactionTitle.tsx +++ b/src/app/components/transactions/transactionTitle.tsx @@ -23,16 +23,17 @@ export default function TransactionTitle(props: TransactionTitleProps) { const { transaction } = props; const { t } = useTranslation('translation', { keyPrefix: 'COIN_DASHBOARD_SCREEN' }); const { coins } = useWalletSelector(); + const isPending = transaction.txStatus === 'pending'; const getTokenTransferTitle = (tx: TransactionData): string => { - if (tx.txStatus === 'pending') { + if (isPending) { return tx.incoming ? t('TRANSACTION_PENDING_RECEIVING') : t('TRANSACTION_PENDING_SENDING'); } return tx.incoming ? t('TRANSACTION_RECEIVED') : t('TRANSACTION_SENT'); }; const getBrc20TokenTitle = (tx: Brc20HistoryTransactionData): string => { - if (tx.txStatus === 'pending') { + if (isPending) { return tx.incoming ? t('TRANSACTION_PENDING_RECEIVING') : t('TRANSACTION_PENDING_SENDING'); } if (tx.operation === 'transfer_send') { @@ -48,7 +49,7 @@ export default function TransactionTitle(props: TransactionTitleProps) { }; const getBtcTokenTransferTitle = (tx: BtcTransactionData): string => { - if (tx.txStatus === 'pending') { + if (isPending) { if (tx.isOrdinal) { return tx.incoming ? t('ORDINAL_TRANSACTION_PENDING_RECEIVING') diff --git a/src/app/hooks/queries/useTransaction.ts b/src/app/hooks/queries/useTransaction.ts new file mode 100644 index 000000000..f1dd82be5 --- /dev/null +++ b/src/app/hooks/queries/useTransaction.ts @@ -0,0 +1,29 @@ +import useBtcClient from '@hooks/useBtcClient'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { fetchBtcTransaction } from '@secretkeylabs/xverse-core'; +import { useQuery } from '@tanstack/react-query'; + +export default function useTransaction(id: string) { + const { selectedAccount } = useWalletSelector(); + const btcClient = useBtcClient(); + + const fetchTransaction = async () => { + if (!selectedAccount || !id) { + return; + } + + const transaction = await fetchBtcTransaction( + id, + selectedAccount.btcAddress, + selectedAccount.ordinalsAddress, + btcClient, + ); + + return transaction; + }; + + return useQuery({ + queryKey: ['transaction', id], + queryFn: fetchTransaction, + }); +} diff --git a/src/app/hooks/queries/useTransactions.ts b/src/app/hooks/queries/useTransactions.ts index 347f273fb..d03c9746d 100644 --- a/src/app/hooks/queries/useTransactions.ts +++ b/src/app/hooks/queries/useTransactions.ts @@ -1,3 +1,4 @@ +import useBtcClient from '@hooks/useBtcClient'; import useWalletSelector from '@hooks/useWalletSelector'; import type { Brc20HistoryTransactionData, BtcTransactionData } from '@secretkeylabs/xverse-core'; import { fetchBtcTransactionsData, getBrc20History } from '@secretkeylabs/xverse-core'; @@ -14,6 +15,8 @@ export default function useTransactions(coinType: CurrencyTypes, brc20Token: str const { network, stxAddress, btcAddress, ordinalsAddress, hasActivatedOrdinalsKey } = useWalletSelector(); const selectedNetwork = useNetworkSelector(); + const btcClient = useBtcClient(); + const fetchTransactions = async (): Promise< | BtcTransactionData[] | (AddressTransactionWithTransfers | MempoolTransaction)[] @@ -26,7 +29,7 @@ export default function useTransactions(coinType: CurrencyTypes, brc20Token: str const btcData = await fetchBtcTransactionsData( btcAddress, ordinalsAddress, - network.type, + btcClient, hasActivatedOrdinalsKey as boolean, ); return btcData; diff --git a/src/app/hooks/useBtcClient.ts b/src/app/hooks/useBtcClient.ts index 729dbea75..33993a117 100644 --- a/src/app/hooks/useBtcClient.ts +++ b/src/app/hooks/useBtcClient.ts @@ -1,40 +1,21 @@ import { BitcoinEsploraApiProvider } from '@secretkeylabs/xverse-core'; -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import useWalletSelector from './useWalletSelector'; const useBtcClient = () => { const { network, btcApiUrl } = useWalletSelector(); const { type, btcApiUrl: remoteBtcApiURL } = network; + const url = btcApiUrl || remoteBtcApiURL; const esploraInstance = useMemo( () => new BitcoinEsploraApiProvider({ - url: remoteBtcApiURL, + url, network: type, }), - [btcApiUrl, type, remoteBtcApiURL], + [url, type], ); - useEffect(() => { - if (btcApiUrl) { - esploraInstance.bitcoinApi.interceptors.request.use( - async (config) => { - config.baseURL = btcApiUrl; - return config; - }, - (error) => Promise.reject(error), - ); - } else { - esploraInstance.bitcoinApi.interceptors.request.use( - async (config) => { - config.baseURL = remoteBtcApiURL; - return config; - }, - (error) => Promise.reject(error), - ); - } - }, [btcApiUrl, remoteBtcApiURL]); - return esploraInstance; }; diff --git a/src/app/hooks/useNonOrdinalUtxo.ts b/src/app/hooks/useNonOrdinalUtxo.ts index 3676b0c9e..edd4a64b5 100644 --- a/src/app/hooks/useNonOrdinalUtxo.ts +++ b/src/app/hooks/useNonOrdinalUtxo.ts @@ -2,19 +2,21 @@ import { getNonOrdinalUtxo, UTXO } from '@secretkeylabs/xverse-core'; import { useQuery } from '@tanstack/react-query'; import { REFETCH_UNSPENT_UTXO_TIME } from '@utils/constants'; import { getTimeForNonOrdinalTransferTransaction } from '@utils/localStorage'; +import useBtcClient from './useBtcClient'; import useWalletSelector from './useWalletSelector'; const useNonOrdinalUtxos = () => { const { network, ordinalsAddress } = useWalletSelector(); + const btcClient = useBtcClient(); const fetchNonOrdinalUtxo = async () => { const lastTransactionTime = await getTimeForNonOrdinalTransferTransaction(ordinalsAddress); if (!lastTransactionTime) { - return getNonOrdinalUtxo(ordinalsAddress, network.type); + return getNonOrdinalUtxo(ordinalsAddress, btcClient, network.type); } const diff = new Date().getTime() - Number(lastTransactionTime); if (diff > REFETCH_UNSPENT_UTXO_TIME) { - return getNonOrdinalUtxo(ordinalsAddress, network.type); + return getNonOrdinalUtxo(ordinalsAddress, btcClient, network.type); } return [] as UTXO[]; }; diff --git a/src/app/hooks/useOrdinalsByAddress.ts b/src/app/hooks/useOrdinalsByAddress.ts index 881d96fc0..79b346e49 100644 --- a/src/app/hooks/useOrdinalsByAddress.ts +++ b/src/app/hooks/useOrdinalsByAddress.ts @@ -1,11 +1,14 @@ import { BtcOrdinal, getOrdinalsByAddress } from '@secretkeylabs/xverse-core'; import { useQuery } from '@tanstack/react-query'; +import useBtcClient from './useBtcClient'; import useWalletSelector from './useWalletSelector'; const useOrdinalsByAddress = (address: string) => { const { network } = useWalletSelector(); + const btcClient = useBtcClient(); + const fetchOrdinals = async (): Promise => { - const ordinals = await getOrdinalsByAddress(network.type, address); + const ordinals = await getOrdinalsByAddress(btcClient, network.type, address); return ordinals.filter((item) => item.id !== undefined); }; diff --git a/src/app/hooks/useSendBtcRequest.ts b/src/app/hooks/useSendBtcRequest.ts index a0c27ad37..c36819fdb 100644 --- a/src/app/hooks/useSendBtcRequest.ts +++ b/src/app/hooks/useSendBtcRequest.ts @@ -5,6 +5,7 @@ import { decodeToken } from 'jsontokens'; import { useState } from 'react'; import { useLocation } from 'react-router-dom'; import { SendBtcTransactionOptions } from 'sats-connect'; +import useBtcClient from './useBtcClient'; import useSeedVault from './useSeedVault'; import useWalletSelector from './useWalletSelector'; @@ -17,6 +18,7 @@ function useSendBtcRequest() { const tabId = params.get('tabId') ?? '0'; const { getSeed } = useSeedVault(); const { network, selectedAccount } = useWalletSelector(); + const btcClient = useBtcClient(); const generateSignedTransaction = async () => { const seedPhrase = await getSeed(); @@ -34,6 +36,7 @@ function useSendBtcRequest() { request.payload?.senderAddress, selectedAccount?.id ?? 0, seedPhrase, + btcClient, network.type, ); return signedTx; diff --git a/src/app/hooks/useSponsoredTransaction.ts b/src/app/hooks/useSponsoredTransaction.ts index bc5573869..e5393daee 100644 --- a/src/app/hooks/useSponsoredTransaction.ts +++ b/src/app/hooks/useSponsoredTransaction.ts @@ -16,7 +16,6 @@ export const useSponsorInfoQuery = (sponsorUrl?: string) => export const useSponsoredTransaction = (isSponsorOptionSelected: boolean, sponsorUrl?: string) => { const [isServiceRunning, setIsServiceRunning] = useState(false); - const { error, data: isActive, isLoading } = useSponsorInfoQuery(sponsorUrl); useEffect(() => { diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx index 0637730fe..1fc350bd0 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/index.tsx @@ -64,6 +64,7 @@ import PrivacyPreferencesScreen from '@screens/settings/privacyPreferences'; import SignatureRequest from '@screens/signatureRequest'; import SignBatchPsbtRequest from '@screens/signBatchPsbtRequest'; import SignPsbtRequest from '@screens/signPsbtRequest'; +import SpeedUpTransactionScreen from '@screens/speedUpTransaction'; import Stacking from '@screens/stacking'; import SwapScreen from '@screens/swap'; import SwapConfirmScreen from '@screens/swap/swapConfirmation'; @@ -262,6 +263,10 @@ const router = createHashRouter([ ), }, + { + path: 'speed-up-tx/:id', + element: , + }, { path: 'create-inscription', element: ( diff --git a/src/app/screens/coinDashboard/transactionsHistoryList.tsx b/src/app/screens/coinDashboard/transactionsHistoryList.tsx index ae38b8735..1405eb6ad 100644 --- a/src/app/screens/coinDashboard/transactionsHistoryList.tsx +++ b/src/app/screens/coinDashboard/transactionsHistoryList.tsx @@ -1,8 +1,11 @@ import BtcTransactionHistoryItem from '@components/transactions/btcTransaction'; import StxTransactionHistoryItem from '@components/transactions/stxTransaction'; import useTransactions from '@hooks/queries/useTransactions'; +import useBtcClient from '@hooks/useBtcClient'; +import useSeedVault from '@hooks/useSeedVault'; +import useWalletSelector from '@hooks/useWalletSelector'; import { animated, config, useSpring } from '@react-spring/web'; -import { BtcTransactionData } from '@secretkeylabs/xverse-core'; +import type { BtcTransactionData } from '@secretkeylabs/xverse-core'; import { AddressTransactionWithTransfers, MempoolTransaction, @@ -10,6 +13,7 @@ import { } from '@stacks/stacks-blockchain-api-types'; import { CurrencyTypes } from '@utils/constants'; import { formatDate } from '@utils/date'; +import { isLedgerAccount } from '@utils/helper'; import { isAddressTransactionWithTransfers, isBrc20Transaction, @@ -163,6 +167,9 @@ const filterTxs = ( export default function TransactionsHistoryList(props: TransactionsHistoryListProps) { const { coin, txFilter, brc20Token } = props; + const { network, selectedAccount, accountType } = useWalletSelector(); + const btcClient = useBtcClient(); + const seedVault = useSeedVault(); const { data, isLoading, isFetching, error } = useTransactions( (coin as CurrencyTypes) || 'STX', brc20Token, @@ -176,6 +183,19 @@ export default function TransactionsHistoryList(props: TransactionsHistoryListPr }); const { t } = useTranslation('translation', { keyPrefix: 'COIN_DASHBOARD_SCREEN' }); + const wallet = selectedAccount + ? { + ...selectedAccount, + accountType: accountType || 'software', + accountId: + isLedgerAccount(selectedAccount) && selectedAccount.deviceAccountIndex + ? selectedAccount.deviceAccountIndex + : selectedAccount.id, + network: network.type, + esploraProvider: btcClient, + seedVault, + } + : undefined; const groupedTxs = useMemo(() => { if (!data?.length) { @@ -203,15 +223,19 @@ export default function TransactionsHistoryList(props: TransactionsHistoryListPr {groupedTxs && !isLoading && Object.keys(groupedTxs).map((group) => ( - + {group} {groupedTxs[group].map((transaction) => { - if (isBtcTransaction(transaction) || isBrc20Transaction(transaction)) { + if (wallet && (isBtcTransaction(transaction) || isBrc20Transaction(transaction))) { return ( - + ); } return ( diff --git a/src/app/screens/confirmBrc20Transaction/editFees.tsx b/src/app/screens/confirmBrc20Transaction/editFees.tsx index c0ee17380..e45bdc046 100644 --- a/src/app/screens/confirmBrc20Transaction/editFees.tsx +++ b/src/app/screens/confirmBrc20Transaction/editFees.tsx @@ -38,15 +38,12 @@ const InputContainer = styled.div<{ withError?: boolean }>((props) => ({ alignItems: 'center', marginTop: props.theme.spacing(4), marginBottom: props.theme.spacing(6), - border: props.withError - ? `1px solid ${props.theme.colors.danger_dark_200}` - : `1px solid ${props.theme.colors.white_800}`, + border: `1px solid ${ + props.withError ? props.theme.colors.danger_dark_200 : props.theme.colors.white_800 + }`, backgroundColor: props.theme.colors.elevation1, - borderRadius: 8, - paddingLeft: props.theme.spacing(5), - paddingRight: props.theme.spacing(5), - paddingTop: props.theme.spacing(5), - paddingBottom: props.theme.spacing(5), + borderRadius: props.theme.radius(1), + padding: props.theme.spacing(5), })); const InputField = styled.input((props) => ({ diff --git a/src/app/screens/confirmInscriptionRequest/index.tsx b/src/app/screens/confirmInscriptionRequest/index.tsx index b8b764574..988ee8fe5 100644 --- a/src/app/screens/confirmInscriptionRequest/index.tsx +++ b/src/app/screens/confirmInscriptionRequest/index.tsx @@ -213,6 +213,7 @@ function ConfirmInscriptionRequest() { btcAddress, selectedAccount?.id ?? 0, seedPhrase, + btcClient, network.type, new BigNumber(txFee), ), diff --git a/src/app/screens/createInscription/index.tsx b/src/app/screens/createInscription/index.tsx index 1159ec276..3151a469c 100644 --- a/src/app/screens/createInscription/index.tsx +++ b/src/app/screens/createInscription/index.tsx @@ -27,6 +27,7 @@ import ConfirmScreen from '@components/confirmScreen'; import useWalletSelector from '@hooks/useWalletSelector'; import { getShortTruncatedAddress } from '@utils/helper'; +import useBtcClient from '@hooks/useBtcClient'; import useSeedVault from '@hooks/useSeedVault'; import Callout from '@ui-library/callout'; import { StyledP } from '@ui-library/common.styled'; @@ -267,12 +268,13 @@ function CreateInscription() { const [feeRate, setFeeRate] = useState(suggestedMinerFeeRate ?? DEFAULT_FEE_RATE); const [feeRates, setFeeRates] = useState(); const { getSeed } = useSeedVault(); + const btcClient = useBtcClient(); const { ordinalsAddress, network, btcAddress, selectedAccount, btcFiatRate, fiatCurrency } = useWalletSelector(); useEffect(() => { - getNonOrdinalUtxo(btcAddress, requestedNetwork.type).then(setUtxos); + getNonOrdinalUtxo(btcAddress, btcClient, requestedNetwork.type).then(setUtxos); }, [btcAddress, requestedNetwork]); useEffect(() => { diff --git a/src/app/screens/ledger/confirmLedgerTransaction/index.tsx b/src/app/screens/ledger/confirmLedgerTransaction/index.tsx index 0208186d5..378cb3223 100644 --- a/src/app/screens/ledger/confirmLedgerTransaction/index.tsx +++ b/src/app/screens/ledger/confirmLedgerTransaction/index.tsx @@ -107,6 +107,7 @@ function ConfirmLedgerTransaction(): JSX.Element { try { const result = await signLedgerMixedBtcTransaction({ transport, + esploraProvider: btcClient, network: network.type, addressIndex, recipients: recipients as Recipient[], @@ -138,6 +139,7 @@ function ConfirmLedgerTransaction(): JSX.Element { try { const result = await signLedgerNativeSegwitBtcTransaction({ transport, + esploraProvider: btcClient, network: network.type, addressIndex, recipients: recipients as Recipient[], diff --git a/src/app/screens/nftDashboard/collectiblesTabs.tsx b/src/app/screens/nftDashboard/collectiblesTabs.tsx index c68db4df1..63508ac86 100644 --- a/src/app/screens/nftDashboard/collectiblesTabs.tsx +++ b/src/app/screens/nftDashboard/collectiblesTabs.tsx @@ -263,7 +263,7 @@ export default function CollectiblesTabs({ text={t('LOAD_MORE')} processing={rareSatsQuery.isFetchingNextPage} disabled={rareSatsQuery.isFetchingNextPage} - onPress={rareSatsQuery.fetchNextPage} + onPress={() => rareSatsQuery.fetchNextPage()} /> )} diff --git a/src/app/screens/nftDashboard/useNftDashboard.tsx b/src/app/screens/nftDashboard/useNftDashboard.tsx index 7476b8e84..86454e91f 100644 --- a/src/app/screens/nftDashboard/useNftDashboard.tsx +++ b/src/app/screens/nftDashboard/useNftDashboard.tsx @@ -183,7 +183,7 @@ export const useNftDashboard = (): NftDashboardState => { text={t('LOAD_MORE')} processing={inscriptionsQuery.isFetchingNextPage} disabled={inscriptionsQuery.isFetchingNextPage} - onPress={inscriptionsQuery.fetchNextPage} + onPress={() => inscriptionsQuery.fetchNextPage()} /> )} diff --git a/src/app/screens/ordinalsCollection/index.tsx b/src/app/screens/ordinalsCollection/index.tsx index 51b28fe7e..1a368089e 100644 --- a/src/app/screens/ordinalsCollection/index.tsx +++ b/src/app/screens/ordinalsCollection/index.tsx @@ -254,7 +254,7 @@ function OrdinalsCollection() { text={t('LOAD_MORE')} processing={isFetchingNextPage} disabled={isFetchingNextPage} - onPress={fetchNextPage} + onPress={() => fetchNextPage()} /> )} diff --git a/src/app/screens/restoreFunds/restoreOrdinals/index.tsx b/src/app/screens/restoreFunds/restoreOrdinals/index.tsx index 613b257a9..a7a97f45f 100644 --- a/src/app/screens/restoreFunds/restoreOrdinals/index.tsx +++ b/src/app/screens/restoreFunds/restoreOrdinals/index.tsx @@ -2,6 +2,7 @@ import ActionButton from '@components/button'; import BottomTabBar from '@components/tabBar'; import TopRow from '@components/topRow'; import useOrdinalDataReducer from '@hooks/stores/useOrdinalReducer'; +import useBtcClient from '@hooks/useBtcClient'; import useOrdinalsByAddress from '@hooks/useOrdinalsByAddress'; import useSeedVault from '@hooks/useSeedVault'; import useWalletSelector from '@hooks/useWalletSelector'; @@ -74,6 +75,8 @@ function RestoreOrdinals() { const [error, setError] = useState(''); const [transferringOrdinalId, setTransferringOrdinalId] = useState(null); const location = useLocation(); + const btcClient = useBtcClient(); + const isRestoreFundFlow = location.state?.isRestoreFundFlow; const ordinalsUtxos = useMemo(() => ordinals?.map((ord) => ord.utxo), [ordinals]); @@ -90,6 +93,7 @@ function RestoreOrdinals() { btcAddress, Number(selectedAccount?.id), seedPhrase, + btcClient, network.type, ordinalsUtxos!, ); diff --git a/src/app/screens/sendBrc20/index.tsx b/src/app/screens/sendBrc20/index.tsx index 5faaf8d88..cb03f1ef2 100644 --- a/src/app/screens/sendBrc20/index.tsx +++ b/src/app/screens/sendBrc20/index.tsx @@ -1,6 +1,7 @@ import ActionButton from '@components/button'; import BottomBar from '@components/tabBar'; import TopRow from '@components/topRow'; +import useBtcClient from '@hooks/useBtcClient'; import { useResetUserFlow } from '@hooks/useResetUserFlow'; import useSeedVault from '@hooks/useSeedVault'; import useWalletSelector from '@hooks/useWalletSelector'; @@ -62,6 +63,7 @@ function SendBrc20Screen() { const [isCreatingOrder, setIsCreatingOrder] = useState(false); const [showForm, setShowForm] = useState(false); const location = useLocation(); + const btcClient = useBtcClient(); const coinTicker = location.search ? location.search.split('coinTicker=')[1] : undefined; const fungibleToken = @@ -103,6 +105,7 @@ function SendBrc20Screen() { fungibleToken.ticker, amountToSend, ordinalsAddress, + btcClient, ); if ((order.inscriptionRequest as any).error) { throw new Error((order.inscriptionRequest as any).error); @@ -147,6 +150,7 @@ function SendBrc20Screen() { btcAddress, selectedAccount?.id ?? 0, seedPhrase, + btcClient, network.type, ); navigate('/confirm-inscription-request', { diff --git a/src/app/screens/sendBrc20OneStep/index.tsx b/src/app/screens/sendBrc20OneStep/index.tsx index aec10bb8e..850c72cc3 100644 --- a/src/app/screens/sendBrc20OneStep/index.tsx +++ b/src/app/screens/sendBrc20OneStep/index.tsx @@ -1,5 +1,6 @@ import BottomBar from '@components/tabBar'; import TopRow from '@components/topRow'; +import useBtcClient from '@hooks/useBtcClient'; import useBtcFeeRate from '@hooks/useBtcFeeRate'; import { useResetUserFlow } from '@hooks/useResetUserFlow'; import useWalletSelector from '@hooks/useWalletSelector'; @@ -35,6 +36,7 @@ function SendBrc20Screen() { const [recipientError, setRecipientError] = useState(null); const [recipientAddress, setRecipientAddress] = useState(''); const [processing, setProcessing] = useState(false); + const btcClient = useBtcClient(); useResetUserFlow('/send-brc20'); @@ -115,7 +117,7 @@ function SendBrc20Screen() { setProcessing(true); // TODO get this from store or cache? - const addressUtxos: UTXO[] = await getNonOrdinalUtxo(btcAddress, network.type); + const addressUtxos: UTXO[] = await getNonOrdinalUtxo(btcAddress, btcClient, network.type); const ticker = getFtTicker(fungibleToken); const numberAmount = Number(replaceCommaByDot(amountToSend)); const estimateFeesParams: Brc20TransferEstimateFeesParams = { diff --git a/src/app/screens/sendBtc/index.tsx b/src/app/screens/sendBtc/index.tsx index b3fa59a18..8169e17fe 100644 --- a/src/app/screens/sendBtc/index.tsx +++ b/src/app/screens/sendBtc/index.tsx @@ -1,6 +1,7 @@ import SendForm from '@components/sendForm'; import BottomBar from '@components/tabBar'; import TopRow from '@components/topRow'; +import useBtcClient from '@hooks/useBtcClient'; import { useResetUserFlow } from '@hooks/useResetUserFlow'; import useSeedVault from '@hooks/useSeedVault'; import useWalletSelector from '@hooks/useWalletSelector'; @@ -41,6 +42,8 @@ function SendBtcScreen() { const { t } = useTranslation('translation', { keyPrefix: 'SEND' }); const navigate = useNavigate(); const { getSeed } = useSeedVault(); + const btcClient = useBtcClient(); + const { isLoading, data, @@ -60,6 +63,7 @@ function SendBtcScreen() { btcAddress, selectedAccount?.id ?? 0, seedPhrase, + btcClient, network.type, ), }); diff --git a/src/app/screens/sendOrdinal/index.tsx b/src/app/screens/sendOrdinal/index.tsx index 74b5f74fe..9f2383cdd 100644 --- a/src/app/screens/sendOrdinal/index.tsx +++ b/src/app/screens/sendOrdinal/index.tsx @@ -125,6 +125,7 @@ function SendOrdinal() { btcAddress, Number(selectedAccount?.id), seedPhrase, + btcClient, network.type, [ordUtxo], ); diff --git a/src/app/screens/sendRareSat/index.tsx b/src/app/screens/sendRareSat/index.tsx index 0dba21e15..833123583 100644 --- a/src/app/screens/sendRareSat/index.tsx +++ b/src/app/screens/sendRareSat/index.tsx @@ -124,6 +124,7 @@ function SendOrdinal() { btcAddress, Number(selectedAccount?.id), seedPhrase, + btcClient, network.type, [ordUtxo], ); diff --git a/src/app/screens/settings/changeNetwork/index.tsx b/src/app/screens/settings/changeNetwork/index.tsx index 085699432..312c2b1ed 100644 --- a/src/app/screens/settings/changeNetwork/index.tsx +++ b/src/app/screens/settings/changeNetwork/index.tsx @@ -88,12 +88,11 @@ function ChangeNetworkScreen() { const { t } = useTranslation('translation', { keyPrefix: 'SETTING_SCREEN' }); const { network, btcApiUrl, networkAddress } = useWalletSelector(); const [changedNetwork, setChangedNetwork] = useState(network); - const [error, setError] = useState(''); + const [stacksUrlError, setStacksUrlError] = useState(''); const [btcURLError, setBtcURLError] = useState(''); const [btcUrl, setBtcUrl] = useState(btcApiUrl || network.btcApiUrl); - const [url, setUrl] = useState(networkAddress || network.address); + const [stacksUrl, setStacksUrl] = useState(networkAddress || network.address); const [isChangingNetwork, setIsChangingNetwork] = useState(false); - const [isUrlEdited, setIsUrlEdited] = useState(false); const navigate = useNavigate(); const { changeNetwork } = useWalletReducer(); @@ -102,27 +101,25 @@ function ChangeNetworkScreen() { }; const onNetworkSelected = (networkSelected: SettingsNetwork) => { - setIsUrlEdited(false); - setUrl(networkSelected.address); + setStacksUrl(networkSelected.address); setChangedNetwork(networkSelected); setBtcUrl(networkSelected.btcApiUrl); - setError(''); + setStacksUrlError(''); setBtcURLError(''); }; const onChangeStacksUrl = (event: React.FormEvent) => { - setError(''); - setUrl(event.currentTarget.value); + setStacksUrlError(''); + setStacksUrl(event.currentTarget.value); }; const onChangeBtcApiUrl = (event: React.FormEvent) => { setBtcURLError(''); - setIsUrlEdited(true); setBtcUrl(event.currentTarget.value); }; const onClearStacksUrl = () => { - setUrl(''); + setStacksUrl(''); }; const onClearBtcUrl = () => { @@ -130,39 +127,39 @@ function ChangeNetworkScreen() { }; const onResetBtcUrl = async () => { - if (changedNetwork.type !== network.type) { - setBtcUrl(changedNetwork.btcApiUrl); - } else { - setBtcUrl(network.btcApiUrl); - } + setBtcUrl(changedNetwork.btcApiUrl); setBtcURLError(''); }; const onResetStacks = async () => { - if (changedNetwork.type !== network.type) { - setUrl(changedNetwork.address); - } else { - setUrl(networkAddress || network.address); - } - setError(''); + setStacksUrl(changedNetwork.address); + setStacksUrlError(''); }; const onSubmit = async () => { setIsChangingNetwork(true); - const isValidStacksUrl = await isValidStacksApi(url, changedNetwork.type).catch((err) => - setError(err.message), - ); - const isValidBtcApiUrl = await isValidBtcApi(btcUrl, changedNetwork.type).catch((err) => - setBtcURLError(err.message), - ); + + const [isValidStacksUrl, isValidBtcApiUrl] = await Promise.all([ + isValidStacksApi(stacksUrl, changedNetwork.type), + isValidBtcApi(btcUrl, changedNetwork.type), + ]); + if (isValidStacksUrl && isValidBtcApiUrl) { const networkObject = - changedNetwork.type === 'Mainnet' ? new StacksMainnet({ url }) : new StacksTestnet({ url }); - const btcChangedUrl = isUrlEdited ? btcUrl : ''; - await changeNetwork(changedNetwork, networkObject, url, btcChangedUrl); + changedNetwork.type === 'Mainnet' + ? new StacksMainnet({ url: stacksUrl }) + : new StacksTestnet({ url: stacksUrl }); + await changeNetwork(changedNetwork, networkObject, stacksUrl, btcUrl); navigate('/settings'); + } else { + if (!isValidStacksUrl) { + setStacksUrlError(t('INVALID_URL')); + } + if (!isValidBtcApiUrl) { + setBtcURLError(t('INVALID_URL')); + } + setIsChangingNetwork(false); } - setIsChangingNetwork(false); }; return ( @@ -186,12 +183,12 @@ function ChangeNetworkScreen() { Reset URL - + - {error} + {stacksUrlError} BTC API URL Reset URL diff --git a/src/app/screens/settings/index.tsx b/src/app/screens/settings/index.tsx index 4de542907..ce754c35f 100644 --- a/src/app/screens/settings/index.tsx +++ b/src/app/screens/settings/index.tsx @@ -9,6 +9,7 @@ import useWalletSelector from '@hooks/useWalletSelector'; import { ChangeActivateOrdinalsAction, ChangeActivateRareSatsAction, + ChangeActivateRBFAction, } from '@stores/wallet/actions/actionCreators'; import { PRIVACY_POLICY_LINK, SUPPORT_LINK, TERMS_LINK } from '@utils/constants'; import { isInOptions, isLedgerAccount } from '@utils/helper'; @@ -41,8 +42,8 @@ const ResetWalletContainer = styled.div((props) => ({ zIndex: 10, background: 'rgba(25, 25, 48, 0.5)', backdropFilter: 'blur(10px)', - paddingLeft: 16, - paddingRight: 16, + paddingLeft: props.theme.spacing(8), + paddingRight: props.theme.spacing(8), paddingTop: props.theme.spacing(50), })); @@ -63,6 +64,7 @@ function Setting() { network, hasActivatedOrdinalsKey, hasActivatedRareSatsKey, + hasActivatedRBFKey, selectedAccount, } = useWalletSelector(); const navigate = useNavigate(); @@ -108,6 +110,10 @@ function Setting() { dispatch(ChangeActivateRareSatsAction(!hasActivatedRareSatsKey)); }; + const switchActivateRBFState = () => { + dispatch(ChangeActivateRBFAction(!hasActivatedRBFKey)); + }; + const openUpdatePasswordScreen = () => { navigate('/change-password'); }; @@ -246,7 +252,17 @@ function Setting() { toggleFunction={switchActivateRareSatsState} toggleValue={hasActivatedRareSatsKey} disabled={!hasActivatedOrdinalsKey} + showDivider + /> + + + )} diff --git a/src/app/screens/speedUpTransaction/customFee.tsx b/src/app/screens/speedUpTransaction/customFee.tsx new file mode 100644 index 000000000..1eacd7cee --- /dev/null +++ b/src/app/screens/speedUpTransaction/customFee.tsx @@ -0,0 +1,220 @@ +import BottomModal from '@components/bottomModal'; +import ActionButton from '@components/button'; +import FiatAmountText from '@components/fiatAmountText'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { getBtcFiatEquivalent } from '@secretkeylabs/xverse-core'; +import InputFeedback from '@ui-library/inputFeedback'; +import BigNumber from 'bignumber.js'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + marginLeft: props.theme.spacing(8), + marginRight: props.theme.spacing(8), +})); + +const InfoContainer = styled.div((props) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: props.theme.spacing(6), + minHeight: 20, +})); + +const TotalFeeText = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + display: 'flex', + columnGap: props.theme.spacing(2), + color: props.theme.colors.white_200, +})); + +const InputContainer = styled.div<{ withError?: boolean }>((props) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + border: `1px solid ${ + props.withError ? props.theme.colors.danger_dark_200 : props.theme.colors.white_800 + }`, + backgroundColor: props.theme.colors.elevation1, + borderRadius: props.theme.radius(1), + marginTop: props.theme.spacing(4), + padding: props.theme.spacing(6), + paddingLeft: props.theme.spacing(8), + paddingRight: props.theme.spacing(8), +})); + +const InputField = styled.input((props) => ({ + ...props.theme.typography.body_medium_m, + backgroundColor: 'transparent', + color: props.theme.colors.white_200, + border: 'transparent', + width: '80%', + '&::-webkit-outer-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + '&::-webkit-inner-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + '&[type=number]': { + '-moz-appearance': 'textfield', + }, +})); + +const InputLabel = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_200, +})); + +const FeeText = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_0, +})); + +const FeeContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const ControlsContainer = styled.div` + display: flex; + gap: 12px; + margin: 24px 16px 40px; +`; + +const StyledInputFeedback = styled(InputFeedback)` + margin-top: ${(props) => props.theme.spacing(2)}px; +`; + +const StyledFiatAmountText = styled(FiatAmountText)((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_400, +})); + +const StyledActionButton = styled(ActionButton)((props) => ({ + 'div, h1': { + ...props.theme.typography.body_medium_m, + }, +})); + +export default function CustomFee({ + visible, + onClose, + onClickApply, + calculateTotalFee, + feeRate, + fee, + initialFeeRate, + initialTotalFee, + minimumFeeRate, + isFeeLoading, + error, +}: { + visible: boolean; + onClose: () => void; + onClickApply: (feeRate: string, fee: string) => void; + calculateTotalFee: (feeRate: string) => Promise; + feeRate?: string; + fee?: string; + initialFeeRate: string; + initialTotalFee: string; + minimumFeeRate?: string; + isFeeLoading: boolean; + error: string; +}) { + const { t } = useTranslation('translation'); + const { btcFiatRate, fiatCurrency } = useWalletSelector(); + const [feeRateInput, setFeeRateInput] = useState(feeRate || minimumFeeRate || initialFeeRate); + const [totalFee, setTotalFee] = useState(fee || initialTotalFee); + + const fetchTotalFee = async () => { + const response = await calculateTotalFee(feeRateInput); + + if (response) { + setTotalFee(response.toString()); + } + }; + + useEffect(() => { + fetchTotalFee(); + }, [feeRateInput]); + + /* callbacks */ + const handleKeyDownFeeRateInput = (e: React.KeyboardEvent) => { + // only allow positive integers + // disable common special characters, including - and . + // eslint-disable-next-line no-useless-escape + if (e.key.match(/^[!-\/:-@[-`{-~]$/)) { + e.preventDefault(); + } + }; + + const handleChangeFeeRateInput = (e: React.ChangeEvent) => { + setFeeRateInput(e.target.value); + }; + + const handleClickApply = () => { + // apply state to parent + onClickApply(feeRateInput, totalFee); + }; + + const fiatFee = totalFee + ? getBtcFiatEquivalent(BigNumber(totalFee), BigNumber(btcFiatRate)) + : BigNumber(0); + + return ( + + + + + + Sats /vB + + + + {error && } + {!error && minimumFeeRate && Number(feeRateInput) >= Number(minimumFeeRate) && ( + <> + + {t('TRANSACTION_SETTING.TOTAL_FEE')} + {value}} + /> + + + + )} + + + + + + + + ); +} diff --git a/src/app/screens/speedUpTransaction/index.styled.ts b/src/app/screens/speedUpTransaction/index.styled.ts new file mode 100644 index 000000000..71889cb67 --- /dev/null +++ b/src/app/screens/speedUpTransaction/index.styled.ts @@ -0,0 +1,130 @@ +import ActionButton from '@components/button'; +import { Faders } from '@phosphor-icons/react'; +import styled from 'styled-components'; + +export const Title = styled.h1((props) => ({ + ...props.theme.typography.headline_s, + color: props.theme.colors.white_0, + marginTop: props.theme.spacing(8), + marginBottom: props.theme.spacing(8), +})); + +export const LoaderContainer = styled.div({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: 'inherit', +}); + +export const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + marginLeft: props.theme.spacing(8), + marginRight: props.theme.spacing(8), +})); + +export const DetailText = styled.span((props) => ({ + ...props.theme.typography.body_m, + color: props.theme.colors.white_200, + marginBottom: props.theme.spacing(4), +})); + +export const HighlightedText = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_0, +})); + +export const ButtonContainer = styled.div` + display: flex; + flex-direction: column; + margin-top: ${(props) => props.theme.spacing(6)}px; + gap: ${(props) => props.theme.spacing(4)}px; +`; + +export const FeeButton = styled.button<{ + isSelected: boolean; + centered?: boolean; +}>((props) => ({ + ...props.theme.body_medium_m, + textAlign: 'left', + color: props.theme.colors.white_0, + backgroundColor: `${props.isSelected ? props.theme.colors.elevation6_600 : 'transparent'}`, + border: `1px solid ${ + props.isSelected ? props.theme.colors.white_800 : props.theme.colors.white_850 + }`, + borderRadius: props.theme.radius(2), + height: 'auto', + display: 'flex', + justifyContent: 'space-between', + alignItems: props.centered ? 'center' : 'flex-start', + transition: 'background-color 0.1s ease-in-out, border 0.1s ease-in-out', + padding: props.theme.spacing(8), + paddingTop: props.theme.spacing(6), + paddingBottom: props.theme.spacing(6), + ':not(:disabled):hover': { + borderColor: props.theme.colors.white_800, + }, + ':disabled': { + cursor: 'not-allowed', + color: props.theme.colors.white_400, + div: { + color: 'inherit', + }, + svg: { + fill: props.theme.colors.white_600, + }, + }, +})); + +export const ControlsContainer = styled.div` + display: flex; + column-gap: 12px; + margin: 38px 16px 40px; +`; + +export const CustomFeeIcon = styled(Faders)({ + transform: 'rotate(90deg)', +}); + +export const FeeButtonLeft = styled.div((props) => ({ + display: 'flex', + alignItems: 'center', + gap: props.theme.spacing(6), +})); + +export const FeeButtonRight = styled.div({ + textAlign: 'right', +}); + +export const SecondaryText = styled.div<{ + alignRight?: boolean; +}>((props) => ({ + ...props.theme.typography.body_medium_s, + color: props.theme.colors.white_200, + marginTop: props.theme.spacing(2), + textAlign: props.alignRight ? 'right' : 'left', +})); + +export const StyledActionButton = styled(ActionButton)((props) => ({ + 'div, h1': { + ...props.theme.typography.body_medium_m, + }, +})); + +export const WarningText = styled.span((props) => ({ + ...props.theme.typography.body_medium_s, + display: 'block', + color: props.theme.colors.danger_light, + marginTop: props.theme.spacing(2), +})); + +export const SuccessActionsContainer = styled.div((props) => ({ + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: props.theme.spacing(6), + paddingLeft: props.theme.spacing(8), + paddingRight: props.theme.spacing(8), + marginBottom: props.theme.spacing(20), + marginTop: props.theme.spacing(20), +})); diff --git a/src/app/screens/speedUpTransaction/index.tsx b/src/app/screens/speedUpTransaction/index.tsx new file mode 100644 index 000000000..b00fdaf78 --- /dev/null +++ b/src/app/screens/speedUpTransaction/index.tsx @@ -0,0 +1,553 @@ +import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg'; +import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg'; +import { delay } from '@common/utils/ledger'; +import BottomModal from '@components/bottomModal'; +import ActionButton from '@components/button'; +import LedgerConnectionView from '@components/ledger/connectLedgerView'; +import TopRow from '@components/topRow'; +import useTransaction from '@hooks/queries/useTransaction'; +import useBtcClient from '@hooks/useBtcClient'; +import useSeedVault from '@hooks/useSeedVault'; +import useWalletSelector from '@hooks/useWalletSelector'; +import Transport from '@ledgerhq/hw-transport-webusb'; +import { CarProfile, Lightning, RocketLaunch, ShootingStar } from '@phosphor-icons/react'; +import { + currencySymbolMap, + getBtcFiatEquivalent, + mempoolApi, + rbf, + RecommendedFeeResponse, + Transport as TransportType, +} from '@secretkeylabs/xverse-core'; +import { isLedgerAccount } from '@utils/helper'; +import BigNumber from 'bignumber.js'; +import { useCallback, useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import { useNavigate, useParams } from 'react-router-dom'; +import { MoonLoader } from 'react-spinners'; +import { useTheme } from 'styled-components'; +import CustomFee from './customFee'; +import { + ButtonContainer, + Container, + ControlsContainer, + CustomFeeIcon, + DetailText, + FeeButton, + FeeButtonLeft, + FeeButtonRight, + HighlightedText, + LoaderContainer, + SecondaryText, + StyledActionButton, + SuccessActionsContainer, + Title, + WarningText, +} from './index.styled'; + +type TierFees = { + enoughFunds: boolean; + fee?: number; + feeRate: number; +}; + +type RbfRecommendedFees = { + medium?: TierFees; + high?: TierFees; + higher?: TierFees; + highest?: TierFees; +}; + +function SpeedUpTransactionScreen() { + const { t } = useTranslation('translation', { keyPrefix: 'SPEED_UP_TRANSACTION' }); + const theme = useTheme(); + const navigate = useNavigate(); + const [showCustomFee, setShowCustomFee] = useState(false); + const { selectedAccount, accountType, network, btcFiatRate, fiatCurrency } = useWalletSelector(); + const seedVault = useSeedVault(); + const { id } = useParams(); + const btcClient = useBtcClient(); + const [rbfTxSummary, setRbfTxSummary] = useState<{ + currentFee: number; + currentFeeRate: number; + minimumRbfFee: number; + minimumRbfFeeRate: number; + }>(); + const [feeRateInput, setFeeRateInput] = useState(); + const [selectedOption, setSelectedOption] = useState(); + const [recommendedFees, setRecommendedFees] = useState(); + const [rbfRecommendedFees, setRbfRecommendedFees] = useState(); + const { data: transaction } = useTransaction(id!); + const [rbfTransaction, setRbfTransaction] = useState(); + const { t: signatureRequestTranslate } = useTranslation('translation', { + keyPrefix: 'SIGNATURE_REQUEST', + }); + const [isModalVisible, setIsModalVisible] = useState(false); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isLedgerConnectButtonDisabled, setIsLedgerConnectButtonDisabled] = useState(false); + const [isConnectSuccess, setIsConnectSuccess] = useState(false); + const [isConnectFailed, setIsConnectFailed] = useState(false); + const [isTxRejected, setIsTxRejected] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [customFeeRate, setCustomFeeRate] = useState(); + const [customTotalFee, setCustomTotalFee] = useState(); + const [customFeeError, setCustomFeeError] = useState(); + + const fetchRbfData = useCallback(async () => { + if (!selectedAccount || !id || !transaction) { + return; + } + + try { + setIsLoading(true); + const rbfTx = new rbf.RbfTransaction(transaction, { + ...selectedAccount, + accountType: accountType || 'software', + accountId: + isLedgerAccount(selectedAccount) && selectedAccount.deviceAccountIndex + ? selectedAccount.deviceAccountIndex + : selectedAccount.id, + network: network.type, + esploraProvider: btcClient, + seedVault, + }); + setRbfTransaction(rbfTx); + + const rbfTransactionSummary = await rbf.getRbfTransactionSummary(btcClient, transaction.txid); + setRbfTxSummary(rbfTransactionSummary); + + const mempoolFees = await mempoolApi.getRecommendedFees(network.type); + setRecommendedFees(mempoolFees); + + const rbfRecommendedFeesResponse = await rbfTx.getRbfRecommendedFees(mempoolFees); + setRbfRecommendedFees(rbfRecommendedFeesResponse); + } catch (err: any) { + console.error(err); + } finally { + setIsLoading(false); + } + }, [selectedAccount, id, transaction, accountType, network.type, seedVault]); + + useEffect(() => { + fetchRbfData(); + }, [fetchRbfData]); + + const handleClickFeeButton = (e: React.MouseEvent) => { + if (e.currentTarget.value === 'custom') { + setShowCustomFee(true); + return; + } + + if (rbfRecommendedFees) { + const feeObj = rbfRecommendedFees[e.currentTarget.value]; + + if (feeObj?.enoughFunds) { + setFeeRateInput(feeObj.feeRate); + setSelectedOption(e.currentTarget.value); + } + } + }; + + const handleGoBack = () => { + // HACKY: navigate back to homepage to give mempool api time to consolidate transactions + // otherwise, the original replaced transaction may still appear + navigate('/'); + }; + + const calculateTotalFee = async (feeRate: string) => { + if (rbfTxSummary && Number(feeRate) < rbfTxSummary?.minimumRbfFeeRate) { + setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary?.minimumRbfFeeRate })); + return; + } + + const feeSummary: { + enoughFunds: boolean; + fee?: number; + feeRate: number; + } = await rbfTransaction.getRbfFeeSummary(Number(feeRate)); + + if (!feeSummary.enoughFunds) { + setCustomFeeError(t('INSUFFICIENT_FUNDS')); + } else { + setCustomFeeError(undefined); + } + + return feeSummary.fee; + }; + + const signAndBroadcastTx = async (transport?: TransportType) => { + if (isLedgerAccount(selectedAccount) && !transport) { + return; + } + + try { + const signedTx = await rbfTransaction.getReplacementTransaction({ + feeRate: Number(feeRateInput), + ledgerTransport: transport, + }); + + await btcClient.sendRawTransaction(signedTx.hex); + + toast.success(t('TX_FEE_UPDATED')); + handleGoBack(); + } catch (err: any) { + console.error(err); + + if (err?.response?.data && err?.response?.data.includes('insufficient fee')) { + toast.error(t('INSUFFICIENT_FEE')); + } + } + }; + + const handleClickSubmit = async () => { + if (!selectedAccount || !id) { + return; + } + + if (isLedgerAccount(selectedAccount)) { + setIsModalVisible(true); + return; + } + + signAndBroadcastTx(); + }; + + const handleConnectAndConfirm = async () => { + if (!selectedAccount) { + console.error('No account selected'); + return; + } + + setIsLedgerConnectButtonDisabled(true); + const transport = await Transport.create(); + if (!transport) { + setIsConnectSuccess(false); + setIsConnectFailed(true); + setIsLedgerConnectButtonDisabled(false); + return; + } + + setIsConnectSuccess(true); + await delay(1500); + setCurrentStepIndex(1); + try { + await signAndBroadcastTx(transport); + } catch (err) { + console.error(err); + setIsTxRejected(true); + } finally { + await transport.close(); + setIsLedgerConnectButtonDisabled(false); + } + }; + + const handleRetry = async () => { + setIsTxRejected(false); + setIsConnectSuccess(false); + setCurrentStepIndex(0); + }; + + const cancelCallback = () => { + setIsModalVisible(false); + }; + + const handleApplyCustomFee = (feeRate: string, fee: string) => { + if (rbfTxSummary && Number(feeRate) < rbfTxSummary?.minimumRbfFeeRate) { + setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary?.minimumRbfFeeRate })); + return; + } + + if (customFeeError) { + if (customFeeError === t('INSUFFICIENT_FUNDS')) { + return; + } + + setCustomFeeError(undefined); + } + + setFeeRateInput(feeRate); + setCustomFeeRate(feeRate); + setCustomTotalFee(fee); + setSelectedOption('custom'); + + setShowCustomFee(false); + }; + + const handleCloseCustomFee = () => { + setShowCustomFee(false); + }; + + const getEstimatedCompletionTime = (feeRate?: number) => { + if (!feeRate || !recommendedFees) { + return '--'; + } + + if (feeRate < recommendedFees?.hourFee) { + return 'several hours or more'; + } + + if (feeRate === recommendedFees?.hourFee) { + return '~1 hour'; + } + + if (feeRate > recommendedFees?.hourFee && feeRate <= recommendedFees?.halfHourFee) { + return '~30 mins'; + } + + return '~10 mins'; + }; + + const feeButtonMapping = { + medium: { + icon: , + title: t('MED_PRIORITY'), + }, + high: { + icon: , + title: t('HIGH_PRIORITY'), + }, + higher: { + icon: , + title: t('HIGHER_PRIORITY'), + }, + highest: { + icon: , + title: t('HIGHEST_PRIORITY'), + }, + }; + + const getFiatAmountString = (fiatAmount: BigNumber) => { + if (!fiatAmount) { + return ''; + } + + if (fiatAmount.isLessThan(0.01)) { + return `< ${currencySymbolMap[fiatCurrency]}0.01 ${fiatCurrency}`; + } + + return ( + `~ ${value}`} + /> + ); + }; + + return ( + <> + + {isLoading ? ( + + + + ) : ( + <> + + {t('TITLE')} + {t('FEE_INFO')} + + {t('CURRENT_FEE')}{' '} + + + + + + + {t('ESTIMATED_COMPLETION_TIME')}{' '} + + {getEstimatedCompletionTime(rbfTxSummary?.currentFeeRate)} + + + + {rbfRecommendedFees && + Object.entries(rbfRecommendedFees) + .sort((a, b) => { + const priorityOrder = ['highest', 'higher', 'high', 'medium']; + return priorityOrder.indexOf(a[0]) - priorityOrder.indexOf(b[0]); + }) + .map(([key, obj]) => ( + + + {feeButtonMapping[key].icon} +
+ {feeButtonMapping[key].title} + {getEstimatedCompletionTime(obj.feeRate)} + + + +
+
+ +
+ {obj.fee ? ( + + ) : ( + '--' + )} +
+ {obj.fee ? ( + + {getFiatAmountString( + getBtcFiatEquivalent(BigNumber(obj.fee), BigNumber(btcFiatRate)), + )} + + ) : ( + -- {fiatCurrency} + )} + {!obj.enoughFunds && {t('INSUFFICIENT_FUNDS')}} +
+
+ ))} + + + +
+ {t('CUSTOM')} + {customFeeRate && ( + <> + + {getEstimatedCompletionTime(Number(customFeeRate))} + + + + + + )} +
+
+ {customFeeRate && customTotalFee ? ( +
+
+ +
+ + {getFiatAmountString( + getBtcFiatEquivalent(BigNumber(customTotalFee), BigNumber(btcFiatRate)), + )} + +
+ ) : ( +
{t('MANUAL_SETTING')}
+ )} +
+
+
+ + + + + + {showCustomFee && ( + + )} + + setIsModalVisible(false)}> + {currentStepIndex === 0 && ( + + )} + {currentStepIndex === 1 && ( + + )} + + + + + + + )} + + ); +} + +export default SpeedUpTransactionScreen; diff --git a/src/app/stores/wallet/actions/actionCreators.ts b/src/app/stores/wallet/actions/actionCreators.ts index 3648dd6c0..587dbf1d6 100644 --- a/src/app/stores/wallet/actions/actionCreators.ts +++ b/src/app/stores/wallet/actions/actionCreators.ts @@ -214,6 +214,13 @@ export function ChangeActivateRareSatsAction( }; } +export function ChangeActivateRBFAction(hasActivatedRBFKey: boolean): actions.ChangeActivateRBF { + return { + type: actions.ChangeHasActivatedRBFKey, + hasActivatedRBFKey, + }; +} + export function SetRareSatsNoticeDismissedAction( rareSatsNoticeDismissed: boolean, ): actions.SetRareSatsNoticeDismissed { diff --git a/src/app/stores/wallet/actions/types.ts b/src/app/stores/wallet/actions/types.ts index df898784b..734210049 100644 --- a/src/app/stores/wallet/actions/types.ts +++ b/src/app/stores/wallet/actions/types.ts @@ -34,6 +34,7 @@ export const SetCoinDataKey = 'SetCoinDataKey'; export const ChangeHasActivatedOrdinalsKey = 'ChangeHasActivatedOrdinalsKey'; export const RareSatsNoticeDismissedKey = 'RareSatsNoticeDismissedKey'; export const ChangeHasActivatedRareSatsKey = 'ChangeHasActivatedRareSatsKey'; +export const ChangeHasActivatedRBFKey = 'ChangeHasActivatedRBFKey'; export const ChangeShowBtcReceiveAlertKey = 'ChangeShowBtcReceiveAlertKey'; export const ChangeShowOrdinalReceiveAlertKey = 'ChangeShowOrdinalReceiveAlertKey'; @@ -80,6 +81,7 @@ export interface WalletState { networkAddress: string | undefined; hasActivatedOrdinalsKey: boolean | undefined; hasActivatedRareSatsKey: boolean | undefined; + hasActivatedRBFKey: boolean | undefined; rareSatsNoticeDismissed: boolean | undefined; showBtcReceiveAlert: boolean | null; showOrdinalReceiveAlert: boolean | null; @@ -195,6 +197,11 @@ export interface ChangeActivateRareSats { hasActivatedRareSatsKey: boolean; } +export interface ChangeActivateRBF { + type: typeof ChangeHasActivatedRBFKey; + hasActivatedRBFKey: boolean; +} + export interface SetRareSatsNoticeDismissed { type: typeof RareSatsNoticeDismissedKey; rareSatsNoticeDismissed: boolean; @@ -247,7 +254,7 @@ export type WalletActions = | GetActiveAccounts | ChangeActivateOrdinals | ChangeActivateRareSats - | SetRareSatsNoticeDismissed + | ChangeActivateRBF | ChangeShowBtcReceiveAlert | ChangeShowOrdinalReceiveAlert | ChangeShowDataCollectionAlert diff --git a/src/app/stores/wallet/reducer.ts b/src/app/stores/wallet/reducer.ts index fee58665e..28000ab51 100644 --- a/src/app/stores/wallet/reducer.ts +++ b/src/app/stores/wallet/reducer.ts @@ -4,6 +4,7 @@ import { ChangeFiatCurrencyKey, ChangeHasActivatedOrdinalsKey, ChangeHasActivatedRareSatsKey, + ChangeHasActivatedRBFKey, ChangeNetworkKey, ChangeShowBtcReceiveAlertKey, ChangeShowDataCollectionAlertKey, @@ -59,6 +60,7 @@ const initialWalletState: WalletState = { btcApiUrl: '', hasActivatedOrdinalsKey: undefined, hasActivatedRareSatsKey: undefined, + hasActivatedRBFKey: true, rareSatsNoticeDismissed: undefined, showBtcReceiveAlert: true, showOrdinalReceiveAlert: true, @@ -189,6 +191,11 @@ const walletReducer = ( ...state, hasActivatedRareSatsKey: action.hasActivatedRareSatsKey, }; + case ChangeHasActivatedRBFKey: + return { + ...state, + hasActivatedRBFKey: action.hasActivatedRBFKey, + }; case RareSatsNoticeDismissedKey: return { ...state, diff --git a/src/app/utils/helper.ts b/src/app/utils/helper.ts index 62483bd93..6c6aec6fe 100644 --- a/src/app/utils/helper.ts +++ b/src/app/utils/helper.ts @@ -132,30 +132,52 @@ export function checkNftExists( export async function isValidStacksApi(url: string, type: NetworkType): Promise { const networkChainId = type === 'Mainnet' ? ChainID.Mainnet : ChainID.Testnet; - if (validUrl.isUri(url)) { + + if (!validUrl.isUri(url)) { + return false; + } + + try { const response = await getStacksInfo(url); if (response) { if (response.network_id !== networkChainId) { - throw new Error('URL not compatible with current Network'); + // incorrect network + return false; } return true; } + } catch (e) { + return false; } - throw new Error('Invalid URL'); + + return false; } export async function isValidBtcApi(url: string, network: NetworkType) { - if (validUrl.isUri(url)) { - const btcClient = new BitcoinEsploraApiProvider({ - network, - url, - }); - const response = await btcClient.getLatestBlockHeight(); - if (response) { - return true; - } + if (!validUrl.isUri(url)) { + return false; + } + + const btcClient = new BitcoinEsploraApiProvider({ + network, + url, + }); + const defaultBtcClient = new BitcoinEsploraApiProvider({ + network, + }); + + try { + const [customHash, defaultHash] = await Promise.all([ + btcClient.getBlockHash(1), + defaultBtcClient.getBlockHash(1), + ]); + // this ensures the URL is for correct network + return customHash === defaultHash; + } catch (e) { + return false; } - throw new Error('Invalid URL'); + + return false; } export const getNetworkType = (stxNetwork) => diff --git a/src/common/utils/legacy-external-message-handler.ts b/src/common/utils/legacy-external-message-handler.ts index 9495e65c7..44ddfc39b 100644 --- a/src/common/utils/legacy-external-message-handler.ts +++ b/src/common/utils/legacy-external-message-handler.ts @@ -298,7 +298,10 @@ export async function handleLegacyExternalMethodFormat( ['createRepeatInscriptions', payload], ]); - const { id } = await triggerRequestWindowOpen(RequestsRoutes.CreateRepeatInscriptions, urlParams); + const { id } = await triggerRequestWindowOpen( + RequestsRoutes.CreateRepeatInscriptions, + urlParams, + ); listenForPopupClose({ id, tabId, diff --git a/src/inpage/sats.inpage.ts b/src/inpage/sats.inpage.ts index 40808184c..d30c4a49c 100644 --- a/src/inpage/sats.inpage.ts +++ b/src/inpage/sats.inpage.ts @@ -186,9 +186,15 @@ const SatsMethodsProvider: BitcoinProvider = { ); document.dispatchEvent(event); return new Promise((resolve, reject) => { - const handleMessage = (eventMessage: MessageEvent) => { - if (!isValidEvent(eventMessage, ExternalSatsMethods.createRepeatInscriptionsResponse)) return; - if (eventMessage.data.payload?.createRepeatInscriptionsRequest !== createRepeatInscriptionsRequest) + const handleMessage = ( + eventMessage: MessageEvent, + ) => { + if (!isValidEvent(eventMessage, ExternalSatsMethods.createRepeatInscriptionsResponse)) + return; + if ( + eventMessage.data.payload?.createRepeatInscriptionsRequest !== + createRepeatInscriptionsRequest + ) return; window.removeEventListener('message', handleMessage); if (eventMessage.data.payload.createRepeatInscriptionsResponse === 'cancel') { diff --git a/src/locales/en.json b/src/locales/en.json index c58d97ca4..00403c39c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -392,10 +392,13 @@ "STANDARD": "Regular", "HIGH": "Fast", "CUSTOM": "Custom", + "CUSTOM_FEE": "Custom fee", + "TOTAL_FEE": "Total fee:", "LOW": "Slow", "FEE_INFO": "Apply a higher fee to help your transaction confirm quickly, especially when the network is congested.", "NONCE": "Nonce", "APPLY": "Apply", + "BACK": "Back", "NONCE_INFO": "You can use a custom nonce to replace a pending transaction with the same nonce.", "SAME_FEE_ERROR": "New fee must be greater than current fee", "GREATER_FEE_ERROR": "Fee is more than available balance", @@ -733,6 +736,8 @@ "TEXT_INPUT_NEW_PASSWORD_LABEL": "New Password", "ENABLE_RARE_SATS": "Enable Rare Sats (experimental)", "ENABLE_RARE_SATS_DETAIL": "Automatically scan and display rare sats in your Ordinals wallet.", + "ENABLE_SPEED_UP_TRANSACTIONS": "Enable speed up transactions", + "ENABLE_SPEED_UP_TRANSACTIONS_DETAIL": "Allows you to speed up unconfirmed transactions by paying a higher fee.", "ADVANCED": "Advanced" }, "OPTIONS_DIALOG": { @@ -759,6 +764,7 @@ "TRANSACTION_SENT": "Sent", "TRANSACTION_RECEIVED": "Received", "MINT": "Mint", + "SPEED_UP": "Speed up", "INSCRIBE_TRANSFER": "Inscribe Transfer", "TRANSACTION_PENDING_RECEIVING": "Receiving", "TRANSACTION_PENDING_SENDING": "Sending", @@ -789,6 +795,24 @@ "VERIFY_ADDRESS_ON_LEDGER": "Verify address on Ledger", "VIEW_ADDRESS": "View address" }, + "SPEED_UP_TRANSACTION": { + "TITLE": "Speed up transaction", + "FEE_INFO": "Use a higher fee to help the transaction confirm earlier.", + "CURRENT_FEE": "Current fee:", + "ESTIMATED_COMPLETION_TIME": "Estimated completion time:", + "CANCEL": "Cancel", + "SUBMIT": "Submit", + "HIGH_PRIORITY": "High priority", + "HIGHER_PRIORITY": "Higher priority", + "HIGHEST_PRIORITY": "Highest priority", + "MED_PRIORITY": "Medium priority", + "CUSTOM": "Custom", + "INSUFFICIENT_FUNDS": "Insufficient funds", + "INSUFFICIENT_FEE": "Insufficient fee", + "MANUAL_SETTING": "Manual setting", + "TX_FEE_UPDATED": "Transaction fee updated", + "FEE_TOO_LOW": "The minimum fee is {{minimumFee}}" + }, "POST_CONDITION_MESSAGE": { "YOU": "You", "CONTRACT": "The contract",