diff --git a/package-lock.json b/package-lock.json index c2af757ff..9098aaf24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xverse-web-extension", - "version": "0.34.0", + "version": "0.34.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "xverse-web-extension", - "version": "0.34.0", + "version": "0.34.1", "dependencies": { "@ledgerhq/hw-transport-webusb": "^6.27.13", "@phosphor-icons/react": "^2.0.10", diff --git a/package.json b/package.json index 4eeb83f78..b9f930fce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xverse-web-extension", "description": "A Bitcoin wallet for Web3", - "version": "0.34.0", + "version": "0.34.1", "private": true, "engines": { "node": "^18.18.2" diff --git a/src/app/components/confirmBtcTransaction/delegateSection.tsx b/src/app/components/confirmBtcTransaction/delegateSection.tsx new file mode 100644 index 000000000..2415e0da2 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/delegateSection.tsx @@ -0,0 +1,114 @@ +import DropDownIcon from '@assets/img/transactions/dropDownIcon.svg'; +import RuneAmount from '@components/confirmBtcTransaction/itemRow/runeAmount'; +import { WarningOctagon } from '@phosphor-icons/react'; +import { animated, config, useSpring } from '@react-spring/web'; +import { RuneSummary } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import Theme from 'theme'; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + background: props.theme.colors.elevation1, + borderRadius: 12, + padding: `${props.theme.space.m} 0`, + justifyContent: 'center', + marginBottom: props.theme.space.s, +})); + +const RowContainer = styled.div((props) => ({ + padding: `0 ${props.theme.space.m}`, + marginTop: `${props.theme.space.m}`, +})); + +const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: props.spaceBetween ? 'space-between' : 'initial', +})); + +const Header = styled(RowCenter)((props) => ({ + padding: `0 ${props.theme.space.m}`, +})); + +const WarningButton = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: ${(props) => props.theme.colors.elevation1}; + padding: ${(props) => props.theme.space.m}; + padding-bottom: 0; +`; + +const DelegationDescription = styled(StyledP)` + padding: ${(props) => props.theme.space.s} ${(props) => props.theme.space.m}; + padding-bottom: ${(props) => props.theme.space.xs}; +`; + +const Title = styled(StyledP)` + margin-left: ${(props) => props.theme.space.xxs}; +`; + +type Props = { + delegations?: RuneSummary['burns']; +}; + +function DelegateSection({ delegations }: Props) { + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + + const [showDelegationInfo, setShowDelegationInfo] = useState(false); + + const slideInStyles = useSpring({ + config: { ...config.gentle, duration: 200 }, + from: { opacity: 0, maxHeight: 0 }, + to: { opacity: 1, maxHeight: 100 }, + reverse: !showDelegationInfo, + }); + + const arrowRotation = useSpring({ + transform: showDelegationInfo ? 'rotate(180deg)' : 'rotate(0deg)', + config: { ...config.stiff }, + }); + + if (!delegations?.length) return null; + + return ( + +
+ + {t('YOU_WILL_DELEGATE')} + +
+ {delegations.map((delegation) => ( + + + + ))} + setShowDelegationInfo((prevState) => !prevState)}> + + + + {t('UNKNOWN_RUNE_RECIPIENTS')} + + + + + + + {t('RUNE_DELEGATION_DESCRIPTION')} + + +
+ ); +} + +export default DelegateSection; diff --git a/src/app/components/confirmBtcTransaction/index.tsx b/src/app/components/confirmBtcTransaction/index.tsx index 7e3f03ab6..3a316abcd 100644 --- a/src/app/components/confirmBtcTransaction/index.tsx +++ b/src/app/components/confirmBtcTransaction/index.tsx @@ -1,6 +1,7 @@ import { delay } from '@common/utils/ledger'; import BottomModal from '@components/bottomModal'; import ActionButton from '@components/button'; +import { Tab } from '@components/tabBar'; import useWalletSelector from '@hooks/useWalletSelector'; import TransportFactory from '@ledgerhq/hw-transport-webusb'; import { RuneSummary, Transport, btcTransaction } from '@secretkeylabs/xverse-core'; @@ -47,6 +48,7 @@ type Props = { outputs: btcTransaction.EnhancedOutput[]; feeOutput?: btcTransaction.TransactionFeeOutput; runeSummary?: RuneSummary; + showCenotaphCallout: boolean; isLoading: boolean; isSubmitting: boolean; isBroadcast?: boolean; @@ -66,6 +68,8 @@ type Props = { onFeeRateSet?: (feeRate: number) => void; feeRate?: number; hasSigHashNone?: boolean; + title?: string; + selectedBottomTab?: Tab; }; function ConfirmBtcTransaction({ @@ -73,6 +77,7 @@ function ConfirmBtcTransaction({ outputs, feeOutput, runeSummary, + showCenotaphCallout, isLoading, isSubmitting, isBroadcast, @@ -89,6 +94,8 @@ function ConfirmBtcTransaction({ onFeeRateSet, feeRate, hasSigHashNone = false, + title, + selectedBottomTab, }: Props) { const [isModalVisible, setIsModalVisible] = useState(false); const [currentStep, setCurrentStep] = useState(Steps.ConnectLedger); @@ -177,14 +184,14 @@ function ConfirmBtcTransaction({ ) : ( <> - {t('REVIEW_TRANSACTION')} + {title || t('REVIEW_TRANSACTION')} {hasSigHashNone && ( !receipt.sourceAddresses.some( - (address) => address === ordinalsAddress || address === btcAddress, + (address) => + (address === ordinalsAddress && receipt.destinationAddress === ordinalsAddress) || + (address === btcAddress && receipt.destinationAddress === btcAddress), ), ) ?? []; const ordinalRuneReceipts = filteredRuneReceipts.filter( diff --git a/src/app/components/confirmBtcTransaction/transactionSummary.tsx b/src/app/components/confirmBtcTransaction/transactionSummary.tsx index 146f7956e..b54f3c913 100644 --- a/src/app/components/confirmBtcTransaction/transactionSummary.tsx +++ b/src/app/components/confirmBtcTransaction/transactionSummary.tsx @@ -14,6 +14,7 @@ import BigNumber from 'bignumber.js'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; +import DelegateSection from './delegateSection'; import AmountWithInscriptionSatribute from './itemRow/amountWithInscriptionSatribute'; import ReceiveSection from './receiveSection'; import TransferSection from './transferSection'; @@ -33,6 +34,7 @@ const WarningCallout = styled(Callout)` type Props = { isPartialTransaction: boolean; + showCenotaphCallout: boolean; inputs: btcTransaction.EnhancedInput[]; outputs: btcTransaction.EnhancedOutput[]; feeOutput?: btcTransaction.TransactionFeeOutput; @@ -48,6 +50,7 @@ type Props = { function TransactionSummary({ isPartialTransaction, + showCenotaphCallout, inputs, outputs, feeOutput, @@ -100,6 +103,8 @@ function TransactionSummary({ const showFeeSelector = !!(feeRate && getFeeForFeeRate && onFeeRateSet); + const hasRuneDelegation = (runeSummary?.burns.length ?? 0) > 0 && isPartialTransaction; + return ( <> {inscriptionToShow && ( @@ -124,12 +129,16 @@ function TransactionSummary({ {isUnConfirmedInput && ( )} + {showCenotaphCallout && ( + + )} {runeSummary?.mint && !runeSummary?.mint?.runeIsOpen && ( )} {runeSummary?.mint && !runeSummary?.mint?.runeIsMintable && ( )} + {hasRuneDelegation && } - + {!hasRuneDelegation && } {hasOutputScript && !runeSummary && } diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx index d60163308..a871566a8 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/index.tsx @@ -47,6 +47,7 @@ import RareSatsBundle from '@screens/rareSatsBundle'; import RareSatsDetailScreen from '@screens/rareSatsDetail/rareSatsDetail'; import Receive from '@screens/receive'; import RestoreFunds from '@screens/restoreFunds'; +import RecoverRunes from '@screens/restoreFunds/recoverRunes'; import RestoreOrdinals from '@screens/restoreFunds/restoreOrdinals'; import RestoreWallet from '@screens/restoreWallet'; // import SendBrc20Screen from '@screens/sendBrc20'; @@ -340,6 +341,10 @@ const router = createHashRouter([ path: 'restore-ordinals', element: , }, + { + path: 'recover-runes', + element: , + }, { path: 'fiat-currency', element: , diff --git a/src/app/screens/confirmBtcTransaction/index.tsx b/src/app/screens/confirmBtcTransaction/index.tsx index 61a9eb014..ce9985a8f 100644 --- a/src/app/screens/confirmBtcTransaction/index.tsx +++ b/src/app/screens/confirmBtcTransaction/index.tsx @@ -242,7 +242,7 @@ function ConfirmBtcTransaction() { description={t('BTC_TRANSFER_DANGER_ALERT_DESC')} buttonText={t('BACK')} onClose={onClosePress} - secondButtonText={t('CONITNUE')} + secondButtonText={t('CONTINUE')} onButtonClick={onClosePress} onSecondButtonClick={onContinueButtonClick} isWarningAlert diff --git a/src/app/screens/confirmInscriptionRequest/index.tsx b/src/app/screens/confirmInscriptionRequest/index.tsx index 6ecc408ce..be57df492 100644 --- a/src/app/screens/confirmInscriptionRequest/index.tsx +++ b/src/app/screens/confirmInscriptionRequest/index.tsx @@ -344,7 +344,7 @@ function ConfirmInscriptionRequest() { description={t('CONFIRM_TRANSACTION.BTC_TRANSFER_DANGER_ALERT_DESC')} buttonText={t('CONFIRM_TRANSACTION.BACK')} onClose={onClosePress} - secondButtonText={t('CONITNUE')} + secondButtonText={t('CONTINUE')} onButtonClick={onClosePress} onSecondButtonClick={onContinueButtonClick} isWarningAlert diff --git a/src/app/screens/restoreFunds/fundsRow.tsx b/src/app/screens/restoreFunds/fundsRow.tsx index 166f9ccf2..522ba104b 100644 --- a/src/app/screens/restoreFunds/fundsRow.tsx +++ b/src/app/screens/restoreFunds/fundsRow.tsx @@ -4,7 +4,6 @@ const Icon = styled.img((props) => ({ marginRight: props.theme.spacing(8), width: 32, height: 32, - borderRadius: 30, })); const TitleText = styled.h1((props) => ({ @@ -34,6 +33,11 @@ const RowContainer = styled.button((props) => ({ background: 'transparent', width: '100%', marginBottom: 12, + ':disabled': { + backgroundColor: props.theme.colors.elevation0, + opacity: 0.5, + cursor: 'not-allowed', + }, })); interface Props { @@ -41,11 +45,12 @@ interface Props { title: string; description: string; onClick: () => void; + disabled?: boolean; } -function FundsRow({ image, title, description, onClick }: Props) { +function FundsRow({ image, title, description, onClick, disabled }: Props) { return ( - + {title} diff --git a/src/app/screens/restoreFunds/index.tsx b/src/app/screens/restoreFunds/index.tsx index 079bbbc41..50d885a49 100644 --- a/src/app/screens/restoreFunds/index.tsx +++ b/src/app/screens/restoreFunds/index.tsx @@ -1,13 +1,15 @@ import OrdinalsIcon from '@assets/img/nftDashboard/ordinals_icon.svg'; +import RuneIcon from '@assets/img/nftDashboard/rune_icon.svg'; import BottomTabBar from '@components/tabBar'; import TopRow from '@components/topRow'; +import useWalletSelector from '@hooks/useWalletSelector'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import FundsRow from './fundsRow'; const RestoreFundTitle = styled.h1((props) => ({ - ...props.theme.body_l, + ...props.theme.typography.body_l, marginBottom: 15, marginTop: 24, marginLeft: 16, @@ -24,6 +26,7 @@ const Container = styled.div({ function RestoreFunds() { const { t } = useTranslation('translation', { keyPrefix: 'RESTORE_FUND_SCREEN' }); + const { hasActivatedOrdinalsKey } = useWalletSelector(); const navigate = useNavigate(); const handleOnCancelClick = () => { @@ -34,6 +37,10 @@ function RestoreFunds() { navigate('/restore-ordinals'); }; + const onRecoverRunesClick = () => { + navigate('/recover-runes'); + }; + return ( <> @@ -41,12 +48,19 @@ function RestoreFunds() { + - + ); } diff --git a/src/app/screens/restoreFunds/recoverRunes/index.tsx b/src/app/screens/restoreFunds/recoverRunes/index.tsx new file mode 100644 index 000000000..cb7997505 --- /dev/null +++ b/src/app/screens/restoreFunds/recoverRunes/index.tsx @@ -0,0 +1,223 @@ +import ConfirmBitcoinTransaction from '@components/confirmBtcTransaction'; +import RuneAmount from '@components/confirmBtcTransaction/itemRow/runeAmount'; +import BottomTabBar from '@components/tabBar'; +import TopRow from '@components/topRow'; +import useBtcFeeRate from '@hooks/useBtcFeeRate'; +import useTransactionContext from '@hooks/useTransactionContext'; +import { TransactionSummary } from '@screens/sendBtc/helpers'; +import { RuneSummary, parseSummaryForRunes, runesTransaction } from '@secretkeylabs/xverse-core'; +import Button from '@ui-library/button'; +import { StyledP } from '@ui-library/common.styled'; +import Spinner from '@ui-library/spinner'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +const ScrollContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + ${(props) => props.theme.scrollbar} + padding: 0 ${(props) => props.theme.space.xs}; +`; + +const Description = styled(StyledP)` + text-align: left; + margin: 0 ${(props) => props.theme.space.m} ${(props) => props.theme.space.l} + ${(props) => props.theme.space.m}; +`; + +const RowContainer = styled.div((props) => ({ + marginBottom: `${props.theme.space.s}`, + background: props.theme.colors.elevation1, + borderRadius: 12, + padding: `${props.theme.space.m} ${props.theme.space.s}`, +})); + +const Container = styled.div((props) => ({ + display: 'flex', + flex: 1, + flexDirection: 'column', + marginTop: props.theme.space.xl, +})); + +const LoaderContainer = styled.div({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flex: 1, +}); + +const ButtonContainer = styled.div((props) => ({ + marginBottom: props.theme.space.l, + display: 'flex', + alignItems: 'flex-end', + padding: `0 ${props.theme.space.m}`, +})); + +// TODO: export this from core +type EnhancedTransaction = Awaited>; + +function RecoverRunes() { + const { t } = useTranslation('translation', { keyPrefix: 'RECOVER_RUNES_SCREEN' }); + const navigate = useNavigate(); + const [error, setError] = useState(''); + const [feeRate, setFeeRate] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isBroadcasting, setIsBroadcasting] = useState(false); + const [enhancedTxn, setEnhancedTxn] = useState(); + const [summary, setSummary] = useState(); + const [runeSummary, setRuneSummary] = useState(); + const [isConfirmTx, setIsConfirmTx] = useState(false); + + const { data: btcFeeRates } = useBtcFeeRate(); + const context = useTransactionContext(); + + const generateTransactionAndSummary = async (desiredFeeRate: number) => { + const tx = await runesTransaction.recoverRunes(context, desiredFeeRate); + const txSummary = await tx.getSummary(); + const txRuneSummary = await parseSummaryForRunes(context, txSummary, context.network); + + return { transaction: tx, summary: txSummary, runeSummary: txRuneSummary }; + }; + + useEffect(() => { + if (!btcFeeRates?.priority) return; + + if (!feeRate) { + setFeeRate(btcFeeRates.priority.toString()); + return; + } + + const buildTx = async () => { + try { + const txDetails = await generateTransactionAndSummary(+feeRate); + setEnhancedTxn(txDetails.transaction); + setSummary(txDetails.summary); + setRuneSummary(txDetails.runeSummary); + } catch (e) { + setEnhancedTxn(undefined); + setSummary(undefined); + if (e instanceof Error) { + if (e.message === 'No runes to recover') { + setError(t('NO_RUNES')); + return; + } + if (e.message.includes('Insufficient funds')) { + setError(t('INSUFFICIENT_FUNDS')); + return; + } + } + setError((e as Error).message); + } finally { + setIsLoading(false); + } + }; + + buildTx(); + }, [context, btcFeeRates, feeRate, t]); + + const calculateFeeForFeeRate = async (desiredFeeRate: number): Promise => { + const { summary: tempSummary } = await generateTransactionAndSummary(desiredFeeRate); + if (tempSummary) return Number(tempSummary.fee); + + return undefined; + }; + + const handleToggleConfirmTx = () => setIsConfirmTx(!isConfirmTx); + const handleOnNavigateBack = () => navigate(-1); + + const onClickTransfer = async () => { + setIsBroadcasting(true); + try { + const txnId = await enhancedTxn?.broadcast(); + navigate('/tx-status', { + state: { + txid: txnId, + currency: 'BTC', + error: '', + }, + }); + } catch (e) { + setError((e as Error).message); + } finally { + setIsBroadcasting(false); + } + }; + + if (!error && !isLoading) { + return !isConfirmTx ? ( + <> + + + {t('DESCRIPTION')} + + + {(runeSummary?.transfers ?? []).map((transfer) => ( + + + + ))} + + +