;
onFeeRateSet?: (feeRate: number) => void;
feeRate?: number;
+ hasSigHashNone?: boolean;
+ title?: string;
+ selectedBottomTab?: Tab;
};
function ConfirmBtcTransaction({
inputs,
outputs,
feeOutput,
+ runeSummary,
+ showCenotaphCallout,
isLoading,
isSubmitting,
isBroadcast,
isError = false,
- token,
- amountToSend,
- recipientAddress,
cancelText,
confirmText,
onConfirm,
@@ -93,9 +93,12 @@ function ConfirmBtcTransaction({
getFeeForFeeRate,
onFeeRateSet,
feeRate,
+ hasSigHashNone = false,
+ title,
+ selectedBottomTab,
}: Props) {
const [isModalVisible, setIsModalVisible] = useState(false);
- const [currentStepIndex, setCurrentStepIndex] = useState(0);
+ const [currentStep, setCurrentStep] = useState(Steps.ConnectLedger);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
const [isConnectSuccess, setIsConnectSuccess] = useState(false);
const [isConnectFailed, setIsConnectFailed] = useState(false);
@@ -108,6 +111,11 @@ function ConfirmBtcTransaction({
const { selectedAccount } = useWalletSelector();
const hideBackButton = !onBackClick;
+ const hasInsufficientRunes =
+ runeSummary?.transfers?.some((transfer) => !transfer.hasSufficientBalance) ?? false;
+ const validMintingRune =
+ !runeSummary?.mint ||
+ (runeSummary?.mint && runeSummary.mint.runeIsOpen && runeSummary.mint.runeIsMintable);
const onConfirmPress = async () => {
if (!isLedgerAccount(selectedAccount)) {
@@ -136,7 +144,15 @@ function ConfirmBtcTransaction({
setIsConnectSuccess(true);
await delay(1500);
- setCurrentStepIndex(1);
+
+ if (currentStep !== Steps.ExternalInputs && currentStep !== Steps.ConfirmTransaction) {
+ setCurrentStep(Steps.ExternalInputs);
+ return;
+ }
+
+ if (currentStep !== Steps.ConfirmTransaction) {
+ setCurrentStep(Steps.ConfirmTransaction);
+ }
try {
onConfirm(transport);
@@ -146,10 +162,16 @@ function ConfirmBtcTransaction({
}
};
+ const goToConfirmationStep = () => {
+ setCurrentStep(Steps.ConfirmTransaction);
+
+ handleConnectAndConfirm();
+ };
+
const handleRetry = async () => {
setIsTxRejected(false);
setIsConnectSuccess(false);
- setCurrentStepIndex(0);
+ setCurrentStep(Steps.ConnectLedger);
};
// TODO: this is a bit naive, but should be correct. We may want to look at the sig hash types of the inputs instead
@@ -162,24 +184,30 @@ function ConfirmBtcTransaction({
) : (
<>
- {t('REVIEW_TRANSACTION')}
+ {title || t('REVIEW_TRANSACTION')}
- {!isBroadcast && }
+ {hasSigHashNone && (
+
+ )}
+ {!isBroadcast && }
)}
setIsModalVisible(false)}>
- {currentStepIndex === 0 && (
-
- )}
- {currentStepIndex === 1 && (
-
- )}
+
-
-
+ {currentStep === Steps.ExternalInputs && !isTxRejected && !isConnectFailed ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
>
diff --git a/src/app/components/confirmBtcTransaction/itemRow/runeAmount.tsx b/src/app/components/confirmBtcTransaction/itemRow/runeAmount.tsx
index cf01aa076..75e315477 100644
--- a/src/app/components/confirmBtcTransaction/itemRow/runeAmount.tsx
+++ b/src/app/components/confirmBtcTransaction/itemRow/runeAmount.tsx
@@ -1,18 +1,12 @@
+import { mapRuneNameToPlaceholder } from '@components/confirmBtcTransaction/utils';
import TokenImage from '@components/tokenImage';
-import { FungibleToken } from '@secretkeylabs/xverse-core';
import Avatar from '@ui-library/avatar';
import { StyledP } from '@ui-library/common.styled';
-import { getFtTicker } from '@utils/tokens';
+import { ftDecimals, getTicker } from '@utils/helper';
import { useTranslation } from 'react-i18next';
import { NumericFormat } from 'react-number-format';
import styled from 'styled-components';
-type Props = {
- amountSats: number;
- token: FungibleToken;
- amountToSend: string;
-};
-
const Container = styled.div({
width: '100%',
display: 'flex',
@@ -24,31 +18,42 @@ const AvatarContainer = styled.div`
margin-right: ${(props) => props.theme.space.xs};
`;
-const ColumnContainer = styled.div({
+const Row = styled.div({
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
- alignItems: 'center',
gap: '24px',
- whiteSpace: 'nowrap',
- overflow: 'hidden',
});
-const NumberTypeContainer = styled.div`
- text-align: right;
+const Column = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
overflow: hidden;
`;
-const EllipsisStyledP = styled(StyledP)`
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+const StyledPRight = styled(StyledP)`
+ word-break: break-all;
+ text-align: end;
`;
-export default function RuneAmount({ amountSats, token, amountToSend }: Props) {
- const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
+type Props = {
+ tokenName: string;
+ amount: string;
+ divisibility: number;
+ hasSufficientBalance?: boolean;
+};
+export default function RuneAmount({
+ tokenName,
+ amount,
+ divisibility,
+ hasSufficientBalance = true,
+}: Props) {
+ const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
+ const amountWithDecimals = ftDecimals(amount, divisibility);
return (
@@ -56,7 +61,7 @@ export default function RuneAmount({ amountSats, token, amountToSend }: Props) {
src={
-
-
+
+
{t('AMOUNT')}
-
- {t('BITCOIN_VALUE')}
-
-
-
(
- {value}
+
+ {value}
+
)}
/>
-
- {`${amountSats} ${t('SATS')}`}
-
-
-
+
+
+ {tokenName}
+
+
);
}
diff --git a/src/app/components/confirmBtcTransaction/ledgerStepView.tsx b/src/app/components/confirmBtcTransaction/ledgerStepView.tsx
new file mode 100644
index 000000000..93791ef5c
--- /dev/null
+++ b/src/app/components/confirmBtcTransaction/ledgerStepView.tsx
@@ -0,0 +1,87 @@
+import InfoIcon from '@assets/img/info.svg';
+import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg';
+import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg';
+import LedgerConnectionView, {
+ ConnectLedgerContainer,
+ ConnectLedgerText,
+} from '@components/ledger/connectLedgerView';
+import LedgerFailView from '@components/ledger/failLedgerView';
+import {
+ ConnectLedgerTitle,
+ InfoImage,
+} from '@screens/ledger/confirmLedgerTransaction/index.styled';
+import { TFunction } from 'react-i18next';
+
+export enum Steps {
+ ConnectLedger = 0,
+ ExternalInputs = 1,
+ ConfirmTransaction = 2,
+}
+
+type Props = {
+ currentStep: Steps;
+ isConnectSuccess: boolean;
+ isConnectFailed: boolean;
+ isTxRejected: boolean;
+ t: TFunction<'translation', 'CONFIRM_TRANSACTION'>;
+ signatureRequestTranslate: TFunction<'translation', 'SIGNATURE_REQUEST'>;
+};
+
+function LedgerStepView({
+ currentStep,
+ isConnectSuccess,
+ isConnectFailed,
+ isTxRejected,
+ t,
+ signatureRequestTranslate,
+}: Props) {
+ switch (currentStep) {
+ case Steps.ConnectLedger:
+ return (
+
+ );
+ case Steps.ExternalInputs:
+ if (isTxRejected || isConnectFailed) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t('LEDGER.INPUTS_WARNING.EXTERNAL_INPUTS')} /
+ {t('LEDGER.INPUTS_WARNING.NON_DEFAULT_SIGHASH')}
+
+ {t('LEDGER.INPUTS_WARNING.SUBTITLE')}
+
+
+ );
+ case Steps.ConfirmTransaction:
+ return (
+
+ );
+ default:
+ return null;
+ }
+}
+
+export default LedgerStepView;
diff --git a/src/app/components/confirmBtcTransaction/mintSection.tsx b/src/app/components/confirmBtcTransaction/mintSection.tsx
new file mode 100644
index 000000000..1edcedcc5
--- /dev/null
+++ b/src/app/components/confirmBtcTransaction/mintSection.tsx
@@ -0,0 +1,100 @@
+import { ArrowRight } from '@phosphor-icons/react';
+import { RuneSummary } from '@secretkeylabs/xverse-core';
+import { StyledP } from '@ui-library/common.styled';
+import { ftDecimals } from '@utils/helper';
+import { useTranslation } from 'react-i18next';
+import { NumericFormat } from 'react-number-format';
+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,
+ paddingTop: props.theme.space.m,
+ justifyContent: 'center',
+ marginBottom: props.theme.space.s,
+}));
+
+const RowCenter = styled.div({
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+});
+
+const AddressLabel = styled(StyledP)((props) => ({
+ marginLeft: props.theme.space.xxs,
+}));
+
+const Header = styled(RowCenter)((props) => ({
+ marginBottom: props.theme.space.m,
+ padding: `0 ${props.theme.space.m}`,
+}));
+
+type Props = {
+ mints?: RuneSummary['mint'][];
+};
+
+function MintSection({ mints }: Props) {
+ const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
+
+ if (!mints) return null;
+
+ return (
+ <>
+ {mints.map(
+ (mint) =>
+ mint && (
+
+
+
+ {t('YOU_WILL_MINT')}
+
+
+
+
+ {t('YOUR_ORDINAL_ADDRESS')}
+
+
+
+
+
+ {t('NAME')}
+
+
+ {mint?.runeName}
+
+
+
+
+ {t('SYMBOL')}
+
+
+ {mint?.symbol}
+
+
+
+
+ {t('AMOUNT')}
+
+ (
+
+ {value}
+
+ )}
+ />
+
+
+ ),
+ )}
+ >
+ );
+}
+
+export default MintSection;
diff --git a/src/app/components/confirmBtcTransaction/receiveSection.tsx b/src/app/components/confirmBtcTransaction/receiveSection.tsx
index 404f3c351..a73fd2f52 100644
--- a/src/app/components/confirmBtcTransaction/receiveSection.tsx
+++ b/src/app/components/confirmBtcTransaction/receiveSection.tsx
@@ -1,6 +1,7 @@
+import RuneAmount from '@components/confirmBtcTransaction/itemRow/runeAmount';
import useWalletSelector from '@hooks/useWalletSelector';
import { ArrowRight } from '@phosphor-icons/react';
-import { btcTransaction } from '@secretkeylabs/xverse-core';
+import { btcTransaction, RuneSummary } from '@secretkeylabs/xverse-core';
import { StyledP } from '@ui-library/common.styled';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -27,13 +28,14 @@ const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({
}));
const Header = styled(RowCenter)((props) => ({
- marginBottom: props.theme.space.m,
padding: `0 ${props.theme.space.m}`,
}));
-const RowContainer = styled.div((props) => ({
- padding: `0 ${props.theme.space.m}`,
+const RowContainer = styled.div<{ noPadding?: boolean; noMargin?: boolean }>((props) => ({
+ padding: props.noPadding ? 0 : `0 ${props.theme.space.m}`,
+ marginTop: props.noMargin ? 0 : `${props.theme.space.m}`,
}));
+
const AddressLabel = styled(StyledP)((props) => ({
marginLeft: props.theme.space.xxs,
}));
@@ -42,9 +44,10 @@ type Props = {
outputs: btcTransaction.EnhancedOutput[];
netAmount: number;
onShowInscription: (inscription: btcTransaction.IOInscription) => void;
+ runeReceipts?: RuneSummary['receipts'];
};
-function ReceiveSection({ outputs, netAmount, onShowInscription }: Props) {
- const { btcAddress, ordinalsAddress } = useWalletSelector();
+function ReceiveSection({ outputs, netAmount, onShowInscription, runeReceipts }: Props) {
+ const { btcAddress, ordinalsAddress, hasActivatedRareSatsKey } = useWalletSelector();
const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
const { outputsToPayment, outputsToOrdinal } = getOutputsWithAssetsToUserAddress({
@@ -53,16 +56,41 @@ function ReceiveSection({ outputs, netAmount, onShowInscription }: Props) {
ordinalsAddress,
});
+ // if receiving runes from own addresses, hide it because it is change, unless it swap addresses (recover runes)
+ const filteredRuneReceipts =
+ runeReceipts?.filter(
+ (receipt) =>
+ !receipt.sourceAddresses.some(
+ (address) =>
+ (address === ordinalsAddress && receipt.destinationAddress === ordinalsAddress) ||
+ (address === btcAddress && receipt.destinationAddress === btcAddress),
+ ),
+ ) ?? [];
+ const ordinalRuneReceipts = filteredRuneReceipts.filter(
+ (receipt) => receipt.destinationAddress === ordinalsAddress,
+ );
+ const paymentRuneReceipts = filteredRuneReceipts.filter(
+ (receipt) => receipt.destinationAddress === btcAddress,
+ );
+
const inscriptionsRareSatsInPayment = outputsToPayment.filter(
- (output) => output.inscriptions.length > 0 || output.satributes.length > 0,
+ (output) =>
+ output.inscriptions.length > 0 || (hasActivatedRareSatsKey && output.satributes.length > 0),
);
const areInscriptionsRareSatsInPayment = inscriptionsRareSatsInPayment.length > 0;
+ const areInscriptionRareSatsInOrdinal = outputsToOrdinal.length > 0;
const amountIsBiggerThanZero = netAmount > 0;
- const showPaymentSection = areInscriptionsRareSatsInPayment || amountIsBiggerThanZero;
+
+ const showOrdinalSection = !!(ordinalRuneReceipts.length || areInscriptionRareSatsInOrdinal);
+ const showPaymentSection = !!(
+ amountIsBiggerThanZero ||
+ paymentRuneReceipts.length ||
+ areInscriptionsRareSatsInPayment
+ );
return (
<>
- {!!outputsToOrdinal.length && (
+ {showOrdinalSection && (
@@ -73,19 +101,33 @@ function ReceiveSection({ outputs, netAmount, onShowInscription }: Props) {
{t('YOUR_ORDINAL_ADDRESS')}
- {outputsToOrdinal
- .sort((a, b) => b.inscriptions.length - a.inscriptions.length)
- .map((output, index) => (
- index + 1}
+ {ordinalRuneReceipts.map((receipt) => (
+
+
- ))}
+
+ ))}
+ {areInscriptionRareSatsInOrdinal && (
+
+ {outputsToOrdinal
+ .sort((a, b) => b.inscriptions.length - a.inscriptions.length)
+ .map((output, index) => (
+ index + 1}
+ />
+ ))}
+
+ )}
)}
{showPaymentSection && (
@@ -99,25 +141,43 @@ function ReceiveSection({ outputs, netAmount, onShowInscription }: Props) {
{t('YOUR_PAYMENT_ADDRESS')}
+ {paymentRuneReceipts.map((receipt) => (
+
+
+
+ ))}
{amountIsBiggerThanZero && (
)}
- {inscriptionsRareSatsInPayment
- .sort((a, b) => b.inscriptions.length - a.inscriptions.length)
- .map((output, index) => (
- index + 1}
- />
- ))}
+ {areInscriptionsRareSatsInPayment && (
+
+ {inscriptionsRareSatsInPayment
+ .sort((a, b) => b.inscriptions.length - a.inscriptions.length)
+ .map((output, index) => (
+ index + 1}
+ />
+ ))}
+
+ )}
)}
>
diff --git a/src/app/components/confirmBtcTransaction/transactionSummary.tsx b/src/app/components/confirmBtcTransaction/transactionSummary.tsx
index d49a5cc3a..b54f3c913 100644
--- a/src/app/components/confirmBtcTransaction/transactionSummary.tsx
+++ b/src/app/components/confirmBtcTransaction/transactionSummary.tsx
@@ -2,9 +2,11 @@ import TransactionDetailComponent from '@components/transactionDetailComponent';
import useWalletSelector from '@hooks/useWalletSelector';
import AssetModal from '@components/assetModal';
+import BurnSection from '@components/confirmBtcTransaction/burnSection';
+import MintSection from '@components/confirmBtcTransaction/mintSection';
import TransferFeeView from '@components/transferFeeView';
import useBtcFeeRate from '@hooks/useBtcFeeRate';
-import { btcTransaction, FungibleToken, getBtcFiatEquivalent } from '@secretkeylabs/xverse-core';
+import { btcTransaction, getBtcFiatEquivalent, RuneSummary } from '@secretkeylabs/xverse-core';
import SelectFeeRate from '@ui-components/selectFeeRate';
import Callout from '@ui-library/callout';
import { BLOG_LINK } from '@utils/constants';
@@ -12,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';
@@ -25,25 +28,17 @@ const Container = styled.div((props) => ({
marginBottom: 12,
}));
-const ScriptCallout = styled(Callout)`
- margin-bottom: ${(props) => props.theme.space.s};
-`;
-const InscribedRareSatWarning = styled(Callout)`
- margin-bottom: ${(props) => props.theme.space.m};
-`;
-
-const UnconfirmedInputCallout = styled(Callout)`
+const WarningCallout = styled(Callout)`
margin-bottom: ${(props) => props.theme.space.m};
`;
type Props = {
isPartialTransaction: boolean;
+ showCenotaphCallout: boolean;
inputs: btcTransaction.EnhancedInput[];
outputs: btcTransaction.EnhancedOutput[];
feeOutput?: btcTransaction.TransactionFeeOutput;
- token?: FungibleToken;
- amountToSend?: string;
- recipientAddress?: string;
+ runeSummary?: RuneSummary;
getFeeForFeeRate?: (
feeRate: number,
useEffectiveFeeRate?: boolean,
@@ -55,12 +50,11 @@ type Props = {
function TransactionSummary({
isPartialTransaction,
+ showCenotaphCallout,
inputs,
outputs,
feeOutput,
- token,
- amountToSend,
- recipientAddress,
+ runeSummary,
isSubmitting,
getFeeForFeeRate,
onFeeRateSet,
@@ -109,8 +103,7 @@ function TransactionSummary({
const showFeeSelector = !!(feeRate && getFeeForFeeRate && onFeeRateSet);
- // TODO - TEMP SOLUTION - we should detect this via the txContext (input/outputs) for proper PSBT support (v2)
- const isRuneTransaction = token && token.protocol === 'runes' && amountToSend && recipientAddress;
+ const hasRuneDelegation = (runeSummary?.burns.length ?? 0) > 0 && isPartialTransaction;
return (
<>
@@ -126,7 +119,7 @@ function TransactionSummary({
)}
{!!showInscribeRareSatWarning && (
-
)}
{isUnConfirmedInput && (
-
+
+ )}
+ {showCenotaphCallout && (
+
+ )}
+ {runeSummary?.mint && !runeSummary?.mint?.runeIsOpen && (
+
+ )}
+ {runeSummary?.mint && !runeSummary?.mint?.runeIsMintable && (
+
)}
+ {hasRuneDelegation && }
@@ -150,9 +151,12 @@ function TransactionSummary({
outputs={outputs}
onShowInscription={setInscriptionToShow}
netAmount={netAmount}
+ runeReceipts={runeSummary?.receipts}
/>
+ {!hasRuneDelegation && }
+
- {!isRuneTransaction && hasOutputScript && }
+ {hasOutputScript && !runeSummary && }
{feeOutput && !showFeeSelector && (
({
marginBottom: props.theme.space.s,
}));
-const RowContainer = styled.div((props) => ({
- padding: `0 ${props.theme.space.m}`,
+const RowContainer = styled.div<{ noPadding?: boolean; noMargin?: boolean }>((props) => ({
+ padding: props.noPadding ? 0 : `0 ${props.theme.space.m}`,
+ marginTop: props.noMargin ? 0 : `${props.theme.space.m}`,
}));
const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({
@@ -34,28 +32,16 @@ const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({
}));
const Header = styled(RowCenter)((props) => ({
- marginBottom: props.theme.space.m,
padding: `0 ${props.theme.space.m}`,
}));
-const StyledStyledP = styled(StyledP)`
- display: flex;
- align-items: center;
-`;
-
-const StyledArrowRight = styled(ArrowRight)({
- marginRight: 4,
-});
-
type Props = {
outputs: btcTransaction.EnhancedOutput[];
inputs: btcTransaction.EnhancedInput[];
isPartialTransaction: boolean;
+ runeTransfers?: RuneSummary['transfers'];
netAmount: number;
onShowInscription: (inscription: btcTransaction.IOInscription) => void;
- token?: FungibleToken;
- amountToSend?: string;
- recipientAddress?: string;
};
// if isPartialTransaction, we use inputs instead of outputs
@@ -63,11 +49,9 @@ function TransferSection({
outputs,
inputs,
isPartialTransaction,
+ runeTransfers,
netAmount,
onShowInscription,
- token,
- amountToSend,
- recipientAddress,
}: Props) {
const { btcAddress, ordinalsAddress } = useWalletSelector();
const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
@@ -91,35 +75,34 @@ function TransferSection({
inscriptionsFromPayment.push(...item.inscriptions);
satributesFromPayment.push(...item.satributes);
});
+ const hasRuneTransfers = (runeTransfers ?? []).length > 0;
+ const hasInscriptionsRareSatsInOrdinal =
+ (isPartialTransaction && inputFromOrdinal.length > 0) || outputsFromOrdinal.length > 0;
- const hasData =
- showAmount ||
- (isPartialTransaction && inputFromOrdinal.length > 0) ||
- outputsFromOrdinal.length > 0;
+ const hasData = showAmount || hasRuneTransfers || hasInscriptionsRareSatsInOrdinal;
if (!hasData) return null;
- const isRuneTransaction = token && amountToSend && recipientAddress;
-
return (
{t('YOU_WILL_TRANSFER')}
- {isRuneTransaction && (
-
-
- {getTruncatedAddress(recipientAddress, 6)}
-
- )}
+ {runeTransfers?.map((transfer) => (
+
+
+
+ ))}
{showAmount && (
- {isRuneTransaction && (
-
- )}
- {!isRuneTransaction && }
+
)}
- {isPartialTransaction
- ? inputFromOrdinal.map((input, index) => (
- index + 1}
- />
- ))
- : outputsFromOrdinal.map((output, index) => (
- index + 1}
- />
- ))}
+ {hasInscriptionsRareSatsInOrdinal && (
+
+ {isPartialTransaction
+ ? inputFromOrdinal.map((input, index) => (
+ index + 1}
+ />
+ ))
+ : outputsFromOrdinal.map((output, index) => (
+ index + 1}
+ />
+ ))}
+
+ )}
);
}
diff --git a/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx b/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx
index 2ff4e71f0..59fbdf898 100644
--- a/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx
+++ b/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx
@@ -36,11 +36,11 @@ const OutputTitleText = styled(StyledP)((props) => ({
marginBottom: props.theme.space.s,
}));
-const ExpandedContainer = styled(animated.div)({
+const ExpandedContainer = styled(animated.div)((props) => ({
display: 'flex',
flexDirection: 'column',
- marginTop: 16,
-});
+ marginTop: props.theme.space.m,
+}));
type Props = {
inputs: btcTransaction.EnhancedInput[];
@@ -76,7 +76,7 @@ function TxInOutput({ inputs, outputs }: Props) {
{isExpanded ? t('INPUT') : t('INPUT_AND_OUTPUT')}
-
+
{isExpanded && (
diff --git a/src/app/components/confirmBtcTransaction/utils.ts b/src/app/components/confirmBtcTransaction/utils.ts
index ededa2dbb..0e6668a74 100644
--- a/src/app/components/confirmBtcTransaction/utils.ts
+++ b/src/app/components/confirmBtcTransaction/utils.ts
@@ -1,4 +1,4 @@
-import { btcTransaction, BundleSatRange } from '@secretkeylabs/xverse-core';
+import { btcTransaction, BundleSatRange, FungibleToken } from '@secretkeylabs/xverse-core';
export type SatRangeTx = {
totalSats: number;
@@ -267,3 +267,13 @@ export const getSatRangesWithInscriptions = ({
return { satRanges: satRangesArray, totalExoticSats };
};
+
+export const mapRuneNameToPlaceholder = (runeName: string): FungibleToken => ({
+ protocol: 'runes',
+ name: runeName,
+ assetName: '',
+ balance: '',
+ principal: '',
+ total_received: '',
+ total_sent: '',
+});
diff --git a/src/app/components/confirmStxTransactionComponent/index.tsx b/src/app/components/confirmStxTransactionComponent/index.tsx
index 7b23effcb..2b5de848e 100644
--- a/src/app/components/confirmStxTransactionComponent/index.tsx
+++ b/src/app/components/confirmStxTransactionComponent/index.tsx
@@ -130,6 +130,7 @@ interface Props {
title?: string;
subTitle?: string;
hasSignatures?: boolean;
+ onFeeChange?: (fee: BigNumber) => void;
}
function ConfirmStxTransactionComponent({
@@ -144,6 +145,7 @@ function ConfirmStxTransactionComponent({
onCancelClick,
skipModal = false,
hasSignatures = false,
+ onFeeChange,
}: Props) {
const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
const { t: signatureRequestTranslate } = useTranslation('translation', {
@@ -249,6 +251,7 @@ function ConfirmStxTransactionComponent({
}
setFee(initialStxTransactions[0], BigInt(fee.toString()));
+ onFeeChange?.(fee);
if (nonce && nonce !== '') {
setNonce(initialStxTransactions[0], BigInt(nonce));
}
diff --git a/src/app/components/copyButton/index.tsx b/src/app/components/copyButton/index.tsx
index 179b12ae9..012d19c88 100644
--- a/src/app/components/copyButton/index.tsx
+++ b/src/app/components/copyButton/index.tsx
@@ -47,7 +47,7 @@ function CopyButton({ text }: Props) {
if (isCopied) {
setTimeout(() => {
setIsCopied(false);
- }, 5000);
+ }, 2000);
}
}, [isCopied]);
@@ -62,6 +62,7 @@ function CopyButton({ text }: Props) {
content={t('COPIED')}
events={['click']}
place="top"
+ hidden={!isCopied}
/>
>
);
diff --git a/src/app/components/explore/FeaturedCard.tsx b/src/app/components/explore/FeaturedCard.tsx
index 9c875a946..dc0a72ab2 100644
--- a/src/app/components/explore/FeaturedCard.tsx
+++ b/src/app/components/explore/FeaturedCard.tsx
@@ -1,13 +1,23 @@
-import { Link } from 'react-router-dom';
+import { trackMixPanel } from '@utils/mixpanel';
import styled from 'styled-components';
-const Card = styled(Link)`
+const Card = styled.div`
+ cursor: pointer;
display: block;
border-radius: 12px;
background-color: ${({ theme }) => theme.colors.elevation2};
height: 182px;
width: 212px;
color: ${({ theme }) => theme.colors.white_0};
+ transition: opacity 0.1s ease;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:active {
+ opacity: 0.6;
+ }
`;
const CardImage = styled.img`
@@ -35,7 +45,23 @@ export interface FeaturedCardProps {
function FeaturedCard({ url, banner, description }: FeaturedCardProps) {
return (
-
+ {
+ trackMixPanel(
+ 'click_app',
+ {
+ link: url,
+ section: 'featured',
+ source: 'web-extension',
+ },
+ { send_immediately: true },
+ () => {
+ window.open(url, '_blank');
+ },
+ 'explore-app',
+ );
+ }}
+ >
{description}
diff --git a/src/app/components/explore/FeaturedCarousel.tsx b/src/app/components/explore/FeaturedCarousel.tsx
index 092b2668c..d831da56e 100644
--- a/src/app/components/explore/FeaturedCarousel.tsx
+++ b/src/app/components/explore/FeaturedCarousel.tsx
@@ -37,7 +37,7 @@ type FeaturedCardCarouselProps = {
function FeaturedCardCarousel({ items }: FeaturedCardCarouselProps) {
return (
-
+
{items.map((item) => (
-
+
))}
diff --git a/src/app/components/explore/RecommendedApps.tsx b/src/app/components/explore/RecommendedApps.tsx
index 09939ab44..f7e2cdd5e 100644
--- a/src/app/components/explore/RecommendedApps.tsx
+++ b/src/app/components/explore/RecommendedApps.tsx
@@ -1,4 +1,4 @@
-import { Link } from 'react-router-dom';
+import { trackMixPanel } from '@utils/mixpanel';
import styled from 'styled-components';
const Container = styled.div`
@@ -9,13 +9,22 @@ const Container = styled.div`
width: 100%;
`;
-const Card = styled(Link)`
+const Card = styled.div`
+ cursor: pointer;
display: flex;
align-items: center;
column-gap: ${({ theme }) => theme.space.m};
width: 100%;
- max-width: 282px;
color: ${({ theme }) => theme.colors.white_0};
+ transition: opacity 0.1s ease;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:active {
+ opacity: 0.6;
+ }
`;
const CardImage = styled.img`
@@ -43,10 +52,29 @@ function RecommendedApps({ items }: Props) {
return (
{items.map((item) => (
-
+ {
+ trackMixPanel(
+ 'click_app',
+ {
+ title: item.name,
+ link: item.url,
+ section: 'recommended',
+ source: 'web-extension',
+ },
+ { send_immediately: true },
+ () => {
+ window.open(item.url, '_blank');
+ },
+ 'explore-app',
+ );
+ }}
+ >
- {item.name}
+ {item.name}
{item.description}
diff --git a/src/app/components/ledger/ledgerAddressComponent/index.tsx b/src/app/components/ledger/ledgerAddressComponent/index.tsx
index 3d94f7f0e..5bedfd15b 100644
--- a/src/app/components/ledger/ledgerAddressComponent/index.tsx
+++ b/src/app/components/ledger/ledgerAddressComponent/index.tsx
@@ -87,6 +87,7 @@ function LedgerAddressComponent({ title, address }: Props) {
content={isCopied ? 'Copied' : title}
events={['hover']}
place="bottom"
+ hidden={!isCopied}
/>
diff --git a/src/app/components/passwordInput/index.tsx b/src/app/components/passwordInput/index.tsx
index 81c86763a..6e406da31 100644
--- a/src/app/components/passwordInput/index.tsx
+++ b/src/app/components/passwordInput/index.tsx
@@ -3,6 +3,7 @@ import EyeSlash from '@assets/img/createPassword/EyeSlash.svg';
import PasswordIcon from '@assets/img/createPassword/Password.svg';
import ActionButton from '@components/button';
import { animated, useTransition } from '@react-spring/web';
+import Button from '@ui-library/button';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled, { useTheme } from 'styled-components';
@@ -20,11 +21,12 @@ interface PasswordInputProps {
stackButtonAlignment?: boolean;
loading?: boolean;
createPasswordFlow?: boolean;
+ autoFocus?: boolean;
}
interface StrengthBarProps {
- strengthColor: string;
- strengthWidth: string;
+ $strengthColor: string;
+ $strengthWidth: string;
}
const Container = styled.div({
@@ -90,9 +92,13 @@ const ButtonsContainer = styled.div((props) => ({
marginBottom: props.theme.spacing(8),
}));
-const Button = styled.button({
+const StyledButton = styled.button({
background: 'none',
display: 'flex',
+ transition: 'opacity 0.1s ease',
+ '&:hover, &:focus': {
+ opacity: 0.8,
+ },
});
const ErrorMessage = styled.h2((props) => ({
@@ -125,9 +131,9 @@ const StrengthBar = styled(animated.div)((props) => ({
borderRadius: props.theme.radius(1),
width: '50%',
div: {
- width: props.strengthWidth,
+ width: props.$strengthWidth,
height: 4,
- backgroundColor: props.strengthColor,
+ backgroundColor: props.$strengthColor,
borderRadius: props.theme.radius(1),
},
}));
@@ -166,6 +172,7 @@ function PasswordInput(props: PasswordInputProps): JSX.Element {
stackButtonAlignment = false,
loading,
createPasswordFlow,
+ autoFocus = false,
} = props;
const { t } = useTranslation('translation', { keyPrefix: 'CREATE_PASSWORD_SCREEN' });
@@ -243,7 +250,7 @@ function PasswordInput(props: PasswordInputProps): JSX.Element {
return (
{t('PASSWORD_STRENGTH_LABEL')}
-
+
{transition((style) => (
))}
@@ -258,7 +265,7 @@ function PasswordInput(props: PasswordInputProps): JSX.Element {
{t('PASSWORD_STRENGTH_LABEL')}
{transition((style) => (
-
+
))}
@@ -271,7 +278,7 @@ function PasswordInput(props: PasswordInputProps): JSX.Element {
{t('PASSWORD_STRENGTH_LABEL')}
{transition((style) => (
-
+
))}
@@ -282,7 +289,7 @@ function PasswordInput(props: PasswordInputProps): JSX.Element {
return (
{t('PASSWORD_STRENGTH_LABEL')}
-
+
{transition((style) => (
))}
@@ -310,25 +317,26 @@ function PasswordInput(props: PasswordInputProps): JSX.Element {
type={isPasswordVisible ? 'text' : 'password'}
value={enteredPassword}
onChange={handlePasswordChange}
+ autoFocus={autoFocus}
/>
-
+
{error && {error}}
{checkPasswordStrength ? renderStrengthBar() : null}
-
+
-
diff --git a/src/app/components/rareSatIcon/rareSatIcon.tsx b/src/app/components/rareSatIcon/rareSatIcon.tsx
index 741bae34f..f8fd7d1c2 100644
--- a/src/app/components/rareSatIcon/rareSatIcon.tsx
+++ b/src/app/components/rareSatIcon/rareSatIcon.tsx
@@ -3,8 +3,10 @@ import FirstTx from '@assets/img/nftDashboard/rareSats/1stx.png';
import TwoDPali from '@assets/img/nftDashboard/rareSats/2Dpali.png';
import ThreeDPali from '@assets/img/nftDashboard/rareSats/3Dpali.png';
import Alpha from '@assets/img/nftDashboard/rareSats/alpha.png';
+import Block286 from '@assets/img/nftDashboard/rareSats/b286.png';
import Block78 from '@assets/img/nftDashboard/rareSats/b78.png';
import Block9 from '@assets/img/nftDashboard/rareSats/b9.png';
+import Block9_450 from '@assets/img/nftDashboard/rareSats/b9_450.png';
import BlackEpic from '@assets/img/nftDashboard/rareSats/black_epic.png';
import BlackLegendary from '@assets/img/nftDashboard/rareSats/black_legendary.png';
import BlackRare from '@assets/img/nftDashboard/rareSats/black_rare.png';
@@ -13,6 +15,7 @@ import Epic from '@assets/img/nftDashboard/rareSats/epic.png';
import FibonacciSequence from '@assets/img/nftDashboard/rareSats/fibonacci.png';
import Hitman from '@assets/img/nftDashboard/rareSats/hitman.png';
import Jpeg from '@assets/img/nftDashboard/rareSats/jpeg.png';
+import Legacy from '@assets/img/nftDashboard/rareSats/legacy.png';
import Legendary from '@assets/img/nftDashboard/rareSats/legendary.png';
import Mythic from '@assets/img/nftDashboard/rareSats/mythic.png';
import Nakamoto from '@assets/img/nftDashboard/rareSats/nakamoto.png';
@@ -42,7 +45,7 @@ interface Props {
}
function RareSatIcon({ type, size }: Props) {
- const src = {
+ const srcByType: Record = {
EPIC: Epic,
LEGENDARY: Legendary,
MYTHIC: Mythic,
@@ -61,19 +64,22 @@ function RareSatIcon({ type, size }: Props) {
PERFECT_PALINCEPTION: Palinception,
PALIBLOCK_PALINDROME: BlockPali,
PALINDROME: Palindrome,
- NAME_PALINDROME: Palindrome,
ALPHA: Alpha,
OMEGA: Omega,
FIRST_TRANSACTION: FirstTx,
BLOCK9: Block9,
+ BLOCK9_450: Block9_450,
BLOCK78: Block78,
+ BLOCK286: Block286,
NAKAMOTO: Nakamoto,
VINTAGE: Vintage,
PIZZA: Pizza,
JPEG: Jpeg,
HITMAN: Hitman,
SILK_ROAD: SilkRoad,
- }[type];
+ LEGACY: Legacy,
+ };
+ const src = srcByType[type];
if (!src) {
return null;
}
diff --git a/src/app/components/receiveCardComponent/index.tsx b/src/app/components/receiveCardComponent/index.tsx
index 20b38d443..668a8870d 100644
--- a/src/app/components/receiveCardComponent/index.tsx
+++ b/src/app/components/receiveCardComponent/index.tsx
@@ -103,7 +103,7 @@ function ReceiveCardComponent({
if (isCopied) {
setTimeout(() => {
setIsCopied(false);
- }, 5000);
+ }, 2000);
}
}, [isCopied]);
diff --git a/src/app/components/requests/requestError.tsx b/src/app/components/requests/requestError.tsx
new file mode 100644
index 000000000..988fc7798
--- /dev/null
+++ b/src/app/components/requests/requestError.tsx
@@ -0,0 +1,73 @@
+import Failure from '@assets/img/send/x_circle.svg';
+import Button from '@ui-library/button';
+import { useTranslation } from 'react-i18next';
+import styled from 'styled-components';
+/**
+ * A component that displays an error message when a request fails. due to an error in the request payload
+ */
+
+const Container = styled.div((props) => ({
+ background: props.theme.colors.elevation0,
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ paddingLeft: props.theme.spacing(8),
+ paddingRight: props.theme.spacing(8),
+ paddingTop: props.theme.spacing(60),
+}));
+
+const Image = styled.img({
+ alignSelf: 'center',
+ width: 88,
+ height: 88,
+});
+
+const HeadingText = styled.h1((props) => ({
+ ...props.theme.typography.headline_xs,
+ color: props.theme.colors.white_0,
+ textAlign: 'center',
+ marginTop: props.theme.spacing(8),
+}));
+
+const BodyText = styled.h1((props) => ({
+ ...props.theme.typography.body_l,
+ color: props.theme.colors.white_200,
+ marginTop: props.theme.spacing(8),
+ textAlign: 'center',
+ overflowWrap: 'break-word',
+ wordWrap: 'break-word',
+ wordBreak: 'break-word',
+ marginBottom: props.theme.spacing(42),
+}));
+
+const CloseButton = styled(Button)((props) => ({
+ marginBottom: props.theme.spacing(42),
+}));
+
+interface RequestErrorProps {
+ errorTitle?: string;
+ error: string;
+ onClose?: () => void;
+}
+
+function RequestError({ error, errorTitle, onClose }: RequestErrorProps) {
+ const { t } = useTranslation('translation', { keyPrefix: 'REQUEST_ERRORS' });
+
+ const handleClose = () => {
+ if (onClose) {
+ onClose();
+ } else {
+ window.close();
+ }
+ };
+ return (
+
+
+ {errorTitle || t('INVALID_REQUEST')}
+ {error}
+
+
+ );
+}
+
+export default RequestError;
diff --git a/src/app/components/seedPhraseInput/index.tsx b/src/app/components/seedPhraseInput/index.tsx
index 2055674f8..d6f54698d 100644
--- a/src/app/components/seedPhraseInput/index.tsx
+++ b/src/app/components/seedPhraseInput/index.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
const Label = styled.label`
- ${(props) => props.theme.body_medium_m};
+ ${(props) => props.theme.typography.body_medium_m};
display: block;
margin-bottom: ${(props) => props.theme.spacing(4)}px;
`;
@@ -14,7 +14,7 @@ const InputGroup = styled.div`
`;
const Input = styled.input`
- ${(props) => props.theme.body_medium_m};
+ ${(props) => props.theme.typography.body_medium_m};
max-width: 144px;
min-height: ${(props) => props.theme.spacing(22)}px;
background-color: ${(props) => props.theme.colors.elevation0};
@@ -52,9 +52,14 @@ type SeedWordInputProps = {
handleKeyDownInput: (e: React.KeyboardEvent) => void;
handlePaste: (pastedText: string) => void;
disabled?: boolean;
+ autoFocus?: boolean;
};
+
const SeedWordInput = React.forwardRef(
- ({ value, index, handleChangeInput, handleKeyDownInput, disabled, handlePaste }, ref) => {
+ (
+ { value, index, handleChangeInput, handleKeyDownInput, disabled, handlePaste, autoFocus },
+ ref,
+ ) => {
const DEV_MODE = process.env.NODE_ENV === 'development';
const [showValue, setShowValue] = useState(DEV_MODE);
@@ -63,7 +68,7 @@ const SeedWordInput = React.forwardRef(
const handlePasteInput = (e: React.ClipboardEvent) => {
e.preventDefault();
- if (DEV_MODE) {
+ if (DEV_MODE || process.env.WALLET_LABEL) {
const { clipboardData } = e;
const pastedText = clipboardData.getData('text');
handlePaste(pastedText);
@@ -89,6 +94,7 @@ const SeedWordInput = React.forwardRef(
onBlur={handleBlurInput}
disabled={disabled}
ref={ref}
+ autoFocus={autoFocus}
/>
@@ -116,7 +122,7 @@ const InputGrid = styled.div<{ visible?: boolean }>`
`;
const ErrorMessage = styled.p<{ visible: boolean }>`
- ${(props) => props.theme.body_s};
+ ${(props) => props.theme.typography.body_s};
visibility: ${(props) => (props.visible ? 'visible' : 'hidden')};
text-align: center;
color: ${(props) => props.theme.colors.feedback.error};
@@ -125,11 +131,16 @@ const ErrorMessage = styled.p<{ visible: boolean }>`
`;
const TransparentButton = styled.button`
- ${(props) => props.theme.body_m};
+ ${(props) => props.theme.typography.body_m};
background-color: transparent;
border: none;
color: ${(props) => props.theme.colors.white_200};
text-decoration: underline;
+ transition: color 0.1s ease;
+ :hover,
+ :focus {
+ color: ${(props) => props.theme.colors.white_400};
+ }
`;
const seedInit: string[] = [];
@@ -210,6 +221,7 @@ export default function SeedPhraseInput({
ref={(el) => {
inputsRef.current[index] = el;
}}
+ autoFocus={index === 0}
/>
))}
@@ -228,12 +240,13 @@ export default function SeedPhraseInput({
inputsRef.current[index] = el;
}}
disabled={!show24Words}
+ autoFocus={index === 0}
/>
);
})}
{seedError}
-
+
{t('HAVE_A_24_WORDS_SEEDPHRASE?', { number: show24Words ? '12' : '24' })}
diff --git a/src/app/components/tabBar/index.tsx b/src/app/components/tabBar/index.tsx
index 3363dc602..64846f5dc 100644
--- a/src/app/components/tabBar/index.tsx
+++ b/src/app/components/tabBar/index.tsx
@@ -92,31 +92,31 @@ function BottomTabBar({ tab }: Props) {
return showBottomBar ? (
-
{loading ? (
) : (
-
+
{
try {
- const broadcastResult: string = await broadcastSignedTransaction(
- tx[0],
- selectedNetwork,
- txAttachment,
- );
- if (broadcastResult) {
+ const txId = await broadcastSignedTransaction(tx[0], selectedNetwork, txAttachment);
+ if (tabId && messageId && rpcMethod) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction': {
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(tx[0].serialize()) },
+ });
+ break;
+ }
+ case 'stx_callContract': {
+ sendCallContractSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: {
+ transaction: buf2hex(tx[0].serialize()),
+ txid: txId,
+ },
+ });
+ break;
+ }
+ default: {
+ sendInternalErrorMessage({ tabId, messageId });
+ }
+ }
+ } else {
finalizeTxSignature({
requestPayload: requestToken,
tabId,
- data: { txId: broadcastResult, txRaw: buf2hex(tx[0].serialize()) },
- });
- navigate('/tx-status', {
- state: {
- txid: broadcastResult,
- currency: 'STX',
- error: '',
- browserTx: true,
- },
+ data: { txId, txRaw: buf2hex(tx[0].serialize()) },
});
}
+ navigate('/tx-status', {
+ state: {
+ txid: txId,
+ currency: 'STX',
+ error: '',
+ browserTx: true,
+ tabId,
+ messageId,
+ rpcMethod,
+ },
+ });
} catch (e) {
- if (e instanceof Error) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'STX',
- error: e.message,
- browserTx: true,
- },
- });
- }
+ sendInternalErrorMessage({ tabId, messageId });
+ navigate('/tx-status', {
+ state: {
+ txid: '',
+ currency: 'STX',
+ error: e instanceof Error ? e.message : 'An error occurred',
+ browserTx: true,
+ tabId,
+ messageId,
+ rpcMethod,
+ },
+ });
}
};
const confirmCallback = (transactions: StacksTransaction[]) => {
if (request?.sponsored) {
+ if (rpcMethod && tabId && messageId) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction': {
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(unsignedTx.serialize()) },
+ });
+ break;
+ }
+ default: {
+ sendInternalErrorMessage({ tabId, messageId });
+ }
+ }
+ }
navigate('/tx-status', {
state: {
sponsored: true,
@@ -218,18 +276,38 @@ export default function ContractCallRequest(props: ContractCallRequestProps) {
},
});
} else if (isMultiSigTx) {
- finalizeTxSignature({
- requestPayload: requestToken,
- tabId,
- data: { txId: '', txRaw: buf2hex(unsignedTx.serialize()) },
- });
+ if (rpcMethod && tabId && messageId) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction': {
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(unsignedTx.serialize()) },
+ });
+ break;
+ }
+ default: {
+ sendInternalErrorMessage({ tabId, messageId });
+ }
+ }
+ } else {
+ finalizeTxSignature({
+ requestPayload: requestToken,
+ tabId,
+ data: { txId: '', txRaw: buf2hex(unsignedTx.serialize()) },
+ });
+ }
window.close();
} else {
broadcastTx(transactions, attachment);
}
};
const cancelCallback = () => {
- finalizeTxSignature({ requestPayload: requestToken, tabId, data: 'cancel' });
+ if (tabId && messageId) {
+ sendUserRejectionMessage({ tabId, messageId });
+ } else {
+ finalizeTxSignature({ requestPayload: requestToken, tabId, data: 'cancel' });
+ }
window.close();
};
diff --git a/src/app/components/transactionsRequests/ContractDeployTransaction.tsx b/src/app/components/transactionsRequests/ContractDeployTransaction.tsx
index 3d0568b37..fe8f1d433 100644
--- a/src/app/components/transactionsRequests/ContractDeployTransaction.tsx
+++ b/src/app/components/transactionsRequests/ContractDeployTransaction.tsx
@@ -1,4 +1,10 @@
import DownloadImage from '@assets/img/webInteractions/ArrowLineDown.svg';
+import {
+ sendDeployContractSuccessResponseMessage,
+ sendInternalErrorMessage,
+ sendSignTransactionSuccessResponseMessage,
+ sendUserRejectionMessage,
+} from '@common/utils/rpc/stx/rpcResponseMessages';
import AccountHeaderComponent from '@components/accountHeader';
import ConfirmStxTransactionComponent from '@components/confirmStxTransactionComponent';
import InfoContainer from '@components/infoContainer';
@@ -101,10 +107,21 @@ interface ContractDeployRequestProps {
sponsored: boolean;
tabId: number;
requestToken: string;
+ messageId: string | null;
+ rpcMethod: string | null;
}
export default function ContractDeployRequest(props: ContractDeployRequestProps) {
- const { unsignedTx, codeBody, contractName, sponsored, tabId, requestToken } = props;
+ const {
+ unsignedTx,
+ codeBody,
+ contractName,
+ sponsored,
+ tabId,
+ requestToken,
+ messageId,
+ rpcMethod,
+ } = props;
const selectedNetwork = useNetworkSelector();
const [hasTabClosed, setHasTabClosed] = useState(false);
const { t } = useTranslation('translation');
@@ -125,26 +142,50 @@ export default function ContractDeployRequest(props: ContractDeployRequestProps)
const broadcastTx = async (tx: StacksTransaction[]) => {
try {
setLoaderForBroadcastingTx(true);
- const broadcastResult = await broadcastSignedTransaction(tx[0], selectedNetwork);
- if (broadcastResult) {
+ const txId = await broadcastSignedTransaction(tx[0], selectedNetwork);
+ if (rpcMethod && messageId && tabId) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction': {
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(tx[0].serialize()) },
+ });
+ break;
+ }
+ case 'stx_deployContract': {
+ sendDeployContractSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { txid: txId, transaction: buf2hex(tx[0].serialize()) },
+ });
+ break;
+ }
+ default:
+ sendInternalErrorMessage({ tabId, messageId });
+ break;
+ }
+ } else {
finalizeTxSignature({
requestPayload: requestToken,
tabId,
- data: { txId: broadcastResult, txRaw: buf2hex(tx[0].serialize()) },
- });
- navigate('/tx-status', {
- state: {
- txid: broadcastResult,
- currency: 'STX',
- error: '',
- browserTx: true,
- tabId,
- requestToken,
- },
+ data: { txId, txRaw: buf2hex(tx[0].serialize()) },
});
}
+
+ navigate('/tx-status', {
+ state: {
+ txid: txId,
+ currency: 'STX',
+ error: '',
+ browserTx: true,
+ tabId,
+ requestToken,
+ },
+ });
} catch (error: any) {
setLoaderForBroadcastingTx(false);
+ sendInternalErrorMessage({ tabId, messageId });
navigate('/tx-status', {
state: {
txid: '',
@@ -160,6 +201,25 @@ export default function ContractDeployRequest(props: ContractDeployRequestProps)
const confirmCallback = (txs: StacksTransaction[]) => {
if (sponsored) {
+ if (rpcMethod && messageId && tabId) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction':
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(unsignedTx.serialize()) },
+ });
+ break;
+ default:
+ sendInternalErrorMessage({ tabId, messageId });
+ break;
+ }
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(unsignedTx.serialize()) },
+ });
+ }
navigate('/tx-status', {
state: {
sponsored: true,
@@ -167,11 +227,26 @@ export default function ContractDeployRequest(props: ContractDeployRequestProps)
},
});
} else if (isMultiSigTx) {
- finalizeTxSignature({
- requestPayload: requestToken,
- tabId,
- data: { txId: '', txRaw: buf2hex(unsignedTx.serialize()) },
- });
+ if (rpcMethod && messageId && tabId) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction':
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: buf2hex(unsignedTx.serialize()) },
+ });
+ break;
+ default:
+ sendInternalErrorMessage({ tabId, messageId });
+ break;
+ }
+ } else {
+ finalizeTxSignature({
+ requestPayload: requestToken,
+ tabId,
+ data: { txId: '', txRaw: buf2hex(unsignedTx.serialize()) },
+ });
+ }
window.close();
} else {
broadcastTx(txs);
@@ -188,6 +263,9 @@ export default function ContractDeployRequest(props: ContractDeployRequestProps)
};
const cancelCallback = () => {
+ if (rpcMethod && messageId && tabId) {
+ sendUserRejectionMessage({ tabId, messageId });
+ }
finalizeTxSignature({ requestPayload: requestToken, tabId, data: 'cancel' });
window.close();
};
diff --git a/src/app/components/transactionsRequests/utils.ts b/src/app/components/transactionsRequests/utils.ts
index 9bce77ab8..fad718a16 100644
--- a/src/app/components/transactionsRequests/utils.ts
+++ b/src/app/components/transactionsRequests/utils.ts
@@ -1,6 +1,6 @@
import {
- ExternalMethods,
MESSAGE_SOURCE,
+ StacksLegacyMethods,
TransactionResponseMessage,
TxResult,
} from '@common/types/message-types';
@@ -15,7 +15,7 @@ export function formatTxSignatureResponse({
}: FormatTxSignatureResponseArgs): TransactionResponseMessage {
return {
source: MESSAGE_SOURCE,
- method: ExternalMethods.transactionResponse,
+ method: StacksLegacyMethods.transactionResponse,
payload: {
transactionRequest: payload,
transactionResponse: response,
diff --git a/src/app/hooks/queries/ordinals/useGetBrc20FungibleTokens.ts b/src/app/hooks/queries/ordinals/useGetBrc20FungibleTokens.ts
index 17d5d09e8..8e7aefd6e 100644
--- a/src/app/hooks/queries/ordinals/useGetBrc20FungibleTokens.ts
+++ b/src/app/hooks/queries/ordinals/useGetBrc20FungibleTokens.ts
@@ -58,7 +58,7 @@ export const useGetBrc20FungibleTokens = () => {
const queryFn = fetchBrc20FungibleTokens(ordinalsAddress, fiatCurrency, network);
return useQuery({
- queryKey: ['brc20-fungible-tokens', ordinalsAddress, network.type],
+ queryKey: ['brc20-fungible-tokens', ordinalsAddress, network.type, fiatCurrency],
queryFn,
enabled: Boolean(network && ordinalsAddress),
});
diff --git a/src/app/hooks/queries/runes/useGetRuneFungibleTokens.ts b/src/app/hooks/queries/runes/useGetRuneFungibleTokens.ts
index e0ceb5a44..cde5a5467 100644
--- a/src/app/hooks/queries/runes/useGetRuneFungibleTokens.ts
+++ b/src/app/hooks/queries/runes/useGetRuneFungibleTokens.ts
@@ -11,9 +11,8 @@ export const useGetRuneFungibleTokens = () => {
const RunesApi = useRunesApi();
return useQuery({
queryKey: ['get-rune-fungible-tokens', network.type, ordinalsAddress],
- // TODO: remove showRunes once available in mainnet as well
enabled: Boolean(network && ordinalsAddress && showRunes),
- queryFn: async () => RunesApi.getRuneFungibleTokens(ordinalsAddress),
+ queryFn: () => RunesApi.getRuneFungibleTokens(ordinalsAddress),
});
};
diff --git a/src/app/hooks/queries/stx/useGetSip10FungibleTokens.ts b/src/app/hooks/queries/stx/useGetSip10FungibleTokens.ts
index 66058ff09..0281be321 100644
--- a/src/app/hooks/queries/stx/useGetSip10FungibleTokens.ts
+++ b/src/app/hooks/queries/stx/useGetSip10FungibleTokens.ts
@@ -36,6 +36,7 @@ export const fetchSip10FungibleTokens =
return {
...ft,
...found,
+ visible: true,
name: found.name || ft.principal.split('.')[1],
};
})
@@ -66,7 +67,7 @@ export const useGetSip10FungibleTokens = () => {
);
return useQuery({
- queryKey: ['sip10-fungible-tokens', network.type, stxAddress],
+ queryKey: ['sip10-fungible-tokens', network.type, stxAddress, fiatCurrency],
queryFn,
enabled: Boolean(network && stxAddress),
});
diff --git a/src/app/hooks/queries/useCoinRates.ts b/src/app/hooks/queries/useCoinRates.ts
index cb87c308b..c5bd9c867 100644
--- a/src/app/hooks/queries/useCoinRates.ts
+++ b/src/app/hooks/queries/useCoinRates.ts
@@ -1,20 +1,22 @@
import useWalletSelector from '@hooks/useWalletSelector';
-import { fetchBtcToCurrencyRate, fetchStxToBtcRate } from '@secretkeylabs/xverse-core';
+import {
+ NetworkType,
+ SupportedCurrency,
+ fetchBtcToCurrencyRate,
+ fetchStxToBtcRate,
+} from '@secretkeylabs/xverse-core';
import { setCoinRatesAction } from '@stores/wallet/actions/actionCreators';
import { useQuery } from '@tanstack/react-query';
+import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
-export const useCoinRates = () => {
- const dispatch = useDispatch();
- const { fiatCurrency, network } = useWalletSelector();
-
+export const useGetRates = (fiatCurrency: SupportedCurrency, networkType: NetworkType) => {
const fetchCoinRates = async () => {
try {
- const btcFiatRate = await fetchBtcToCurrencyRate(network.type, {
+ const btcFiatRate = await fetchBtcToCurrencyRate(networkType, {
fiatCurrency,
});
- const stxBtcRate = await fetchStxToBtcRate(network.type);
- dispatch(setCoinRatesAction(stxBtcRate, btcFiatRate));
+ const stxBtcRate = await fetchStxToBtcRate(networkType);
return { stxBtcRate, btcFiatRate };
} catch (e: any) {
return Promise.reject(e);
@@ -22,10 +24,23 @@ export const useCoinRates = () => {
};
return useQuery({
- queryKey: ['coin_rates', fiatCurrency, network.type],
+ queryKey: ['coin_rates', fiatCurrency, networkType],
queryFn: fetchCoinRates,
staleTime: 5 * 60 * 1000, // 5 min
});
};
+export const useCoinRates = () => {
+ const dispatch = useDispatch();
+ const { fiatCurrency, network } = useWalletSelector();
+
+ const { data } = useGetRates(fiatCurrency, network.type);
+ useEffect(() => {
+ if (!data?.btcFiatRate || !data?.stxBtcRate) {
+ return;
+ }
+ dispatch(setCoinRatesAction(data.stxBtcRate, data.btcFiatRate));
+ }, [data?.btcFiatRate]);
+};
+
export default useCoinRates;
diff --git a/src/app/hooks/queries/useDelegationState.ts b/src/app/hooks/queries/useDelegationState.ts
new file mode 100644
index 000000000..91fa3e559
--- /dev/null
+++ b/src/app/hooks/queries/useDelegationState.ts
@@ -0,0 +1,18 @@
+import useNetworkSelector from '@hooks/useNetwork';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { fetchDelegationState } from '@secretkeylabs/xverse-core';
+import { useQuery } from '@tanstack/react-query';
+
+const useDelegationState = () => {
+ const { stxAddress } = useWalletSelector();
+ const selectedNetwork = useNetworkSelector();
+ const networkType = selectedNetwork.isMainnet() ? 'Mainnet' : 'Testnet';
+
+ return useQuery({
+ queryKey: ['stacking-delegation-state', networkType, stxAddress],
+ queryFn: () => fetchDelegationState(stxAddress, selectedNetwork),
+ enabled: Boolean(stxAddress),
+ });
+};
+
+export default useDelegationState;
diff --git a/src/app/hooks/queries/useStackingData.ts b/src/app/hooks/queries/useStackingData.ts
index fcaf9d77f..d61641256 100644
--- a/src/app/hooks/queries/useStackingData.ts
+++ b/src/app/hooks/queries/useStackingData.ts
@@ -1,5 +1,4 @@
import {
- fetchDelegationState,
fetchPoolStackerInfo,
fetchStackingPoolInfo,
getStacksInfo,
@@ -7,12 +6,12 @@ import {
} from '@secretkeylabs/xverse-core';
import { useQueries } from '@tanstack/react-query';
import { useCallback } from 'react';
-import useNetworkSelector from '../useNetwork';
import useWalletSelector from '../useWalletSelector';
+import useDelegationState from './useDelegationState';
const useStackingData = () => {
const { stxAddress, network } = useWalletSelector();
- const selectedNetwork = useNetworkSelector();
+ const { data: delegationStateData, isLoading: delegateStateIsLoading } = useDelegationState();
const results = useQueries({
queries: [
@@ -20,10 +19,6 @@ const useStackingData = () => {
queryKey: ['stacking-core-info', network],
queryFn: () => getStacksInfo(network.address),
},
- {
- queryKey: ['stacking-delegation-state', stxAddress, network, selectedNetwork],
- queryFn: () => fetchDelegationState(stxAddress, selectedNetwork),
- },
{
queryKey: ['stacking-pool-info', network.type],
queryFn: () => fetchStackingPoolInfo(network.type),
@@ -36,12 +31,10 @@ const useStackingData = () => {
});
const coreInfoData = results[0].data;
- const delegationStateData = results[1].data;
- const poolInfoData = results[2].data;
- const stackerInfoData = results[3].data;
+ const poolInfoData = results[1].data;
+ const stackerInfoData = results[2].data;
- const isStackingLoading = results.some((result) => result.isLoading);
- const stackingError = results.find(({ error }) => error != null)?.error;
+ const isStackingLoading = results.some((result) => result.isLoading) || delegateStateIsLoading;
const refetchStackingData = useCallback(() => {
results.forEach((result) => result.refetch());
}, [results]);
@@ -55,7 +48,6 @@ const useStackingData = () => {
return {
isStackingLoading,
- stackingError,
stackingData,
refetchStackingData,
};
diff --git a/src/app/hooks/useBtcAddressRequest.ts b/src/app/hooks/useBtcAddressRequest.ts
deleted file mode 100644
index eae5089e7..000000000
--- a/src/app/hooks/useBtcAddressRequest.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types';
-import useWalletSelector from '@hooks/useWalletSelector';
-import { decodeToken } from 'jsontokens';
-import { useLocation } from 'react-router-dom';
-import {
- Address,
- AddressPurpose,
- AddressType,
- GetAddressOptions,
- GetAddressResponse,
-} from 'sats-connect';
-
-const useBtcAddressRequest = () => {
- const {
- btcAddress,
- ordinalsAddress,
- btcPublicKey,
- ordinalsPublicKey,
- stxAddress,
- stxPublicKey,
- selectedAccount,
- } = useWalletSelector();
- const { search } = useLocation();
- const params = new URLSearchParams(search);
- const requestToken = params.get('addressRequest') ?? '';
- const request = decodeToken(requestToken) as any as GetAddressOptions;
- const tabId = params.get('tabId') ?? '0';
- const origin = params.get('origin') ?? '';
-
- const approveBtcAddressRequest = () => {
- const addressesResponse: Address[] = request.payload.purposes.map((purpose: AddressPurpose) => {
- if (purpose === AddressPurpose.Ordinals) {
- return {
- address: ordinalsAddress,
- publicKey: ordinalsPublicKey,
- purpose: AddressPurpose.Ordinals,
- addressType: AddressType.p2tr,
- };
- }
- if (purpose === AddressPurpose.Stacks) {
- return {
- address: stxAddress,
- publicKey: stxPublicKey,
- purpose: AddressPurpose.Stacks,
- addressType: AddressType.stacks,
- };
- }
- return {
- address: btcAddress,
- publicKey: btcPublicKey,
- purpose: AddressPurpose.Payment,
- addressType:
- selectedAccount?.accountType === 'ledger' ? AddressType.p2wpkh : AddressType.p2sh,
- };
- });
- const response: GetAddressResponse = {
- addresses: addressesResponse,
- };
- const addressMessage = {
- source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.getAddressResponse,
- payload: { addressRequest: requestToken, addressResponse: response },
- };
- chrome.tabs.sendMessage(+tabId, addressMessage);
- };
-
- const cancelAddressRequest = () => {
- const addressMessage = {
- source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.getAddressResponse,
- payload: { addressRequest: requestToken, addressResponse: 'cancel' },
- };
- chrome.tabs.sendMessage(+tabId, addressMessage);
- };
-
- return {
- payload: request.payload,
- tabId,
- origin,
- requestToken,
- approveBtcAddressRequest,
- cancelAddressRequest,
- };
-};
-
-export default useBtcAddressRequest;
diff --git a/src/app/hooks/useFeaturedApps.ts b/src/app/hooks/useFeaturedDapps.ts
similarity index 100%
rename from src/app/hooks/useFeaturedApps.ts
rename to src/app/hooks/useFeaturedDapps.ts
diff --git a/src/app/hooks/useHasFeature.ts b/src/app/hooks/useHasFeature.ts
index f0262348b..86b914407 100644
--- a/src/app/hooks/useHasFeature.ts
+++ b/src/app/hooks/useHasFeature.ts
@@ -1,14 +1,19 @@
+import { FeatureId, getXverseApiClient } from '@secretkeylabs/xverse-core';
+import { useQuery } from '@tanstack/react-query';
import useWalletSelector from './useWalletSelector';
-type FeatureId = 'RUNES_SUPPORT';
+const useAppFeatures = () => {
+ const { network, masterPubKey } = useWalletSelector();
-export default function useHasFeature(feature: FeatureId): boolean {
- const { network } = useWalletSelector();
+ return useQuery({
+ queryKey: ['appFeatures', network.type, masterPubKey],
+ queryFn: () => getXverseApiClient(network.type).getAppFeatures({ masterPubKey }),
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ cacheTime: 1000 * 60 * 60 * 24, // 24 hours
+ });
+};
- switch (feature) {
- case 'RUNES_SUPPORT':
- return network?.type === 'Testnet';
- default:
- return false;
- }
+export default function useHasFeature(feature: FeatureId): boolean {
+ const { data } = useAppFeatures();
+ return data?.[feature]?.enabled ?? false;
}
diff --git a/src/app/hooks/useNotificationBanners.ts b/src/app/hooks/useNotificationBanners.ts
new file mode 100644
index 000000000..7b2bfb852
--- /dev/null
+++ b/src/app/hooks/useNotificationBanners.ts
@@ -0,0 +1,40 @@
+import { getXverseApiClient } from '@secretkeylabs/xverse-core';
+import { useQuery } from '@tanstack/react-query';
+import { useEffect, useState } from 'react';
+import useWalletSelector from './useWalletSelector';
+import useWalletSession from './useWalletSession';
+
+function useNotificationBanners() {
+ const { network } = useWalletSelector();
+ const { getSessionStartTime } = useWalletSession();
+ const [sessionStartTime, setSessionStartTime] = useState(null);
+
+ const fetchSessionStartTime = async () => {
+ const time = await getSessionStartTime();
+ setSessionStartTime(time);
+ };
+
+ useEffect(() => {
+ fetchSessionStartTime();
+ }, []);
+
+ const fetchNotificationBanners = async () => {
+ const response = await getXverseApiClient(network.type).getNotificationBanners();
+
+ return response;
+ };
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['notificationBanners', sessionStartTime],
+ queryFn: fetchNotificationBanners,
+ staleTime: 60 * 60 * 1000, // 1 hour
+ });
+
+ return {
+ data,
+ isLoading,
+ refetch,
+ };
+}
+
+export default useNotificationBanners;
diff --git a/src/app/hooks/useRunesApi.ts b/src/app/hooks/useRunesApi.ts
index afc1ee287..5b2f925ec 100644
--- a/src/app/hooks/useRunesApi.ts
+++ b/src/app/hooks/useRunesApi.ts
@@ -1,10 +1,10 @@
-import { RunesApi } from '@secretkeylabs/xverse-core';
+import { getRunesClient } from '@secretkeylabs/xverse-core';
import { useMemo } from 'react';
import useWalletSelector from './useWalletSelector';
const useRunesApi = () => {
const { network } = useWalletSelector();
- return useMemo(() => new RunesApi(network.type), [network.type]);
+ return useMemo(() => getRunesClient(network.type), [network.type]);
};
export default useRunesApi;
diff --git a/src/app/hooks/useSendBtcRequest.ts b/src/app/hooks/useSendBtcRequest.ts
deleted file mode 100644
index c36819fdb..000000000
--- a/src/app/hooks/useSendBtcRequest.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Recipient, signBtcTransaction } from '@secretkeylabs/xverse-core';
-import { useQuery } from '@tanstack/react-query';
-import BigNumber from 'bignumber.js';
-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';
-
-function useSendBtcRequest() {
- const { search } = useLocation();
- const params = new URLSearchParams(search);
- const [recipient, setRecipient] = useState([]);
- const requestToken = params.get('sendBtcRequest') ?? '';
- const request = decodeToken(requestToken) as any as SendBtcTransactionOptions;
- const tabId = params.get('tabId') ?? '0';
- const { getSeed } = useSeedVault();
- const { network, selectedAccount } = useWalletSelector();
- const btcClient = useBtcClient();
-
- const generateSignedTransaction = async () => {
- const seedPhrase = await getSeed();
- const recipients: Recipient[] = [];
- request.payload?.recipients?.forEach(async (value) => {
- const txRecipient: Recipient = {
- address: value.address,
- amountSats: new BigNumber(value.amountSats.toString()),
- };
- recipients.push(txRecipient);
- });
- setRecipient(recipients);
- const signedTx = await signBtcTransaction(
- recipients,
- request.payload?.senderAddress,
- selectedAccount?.id ?? 0,
- seedPhrase,
- btcClient,
- network.type,
- );
- return signedTx;
- };
-
- const {
- data: signedTx,
- isLoading,
- error,
- } = useQuery({
- queryKey: ['generate-signed-transaction'],
- queryFn: generateSignedTransaction,
- });
-
- return {
- payload: request.payload,
- tabId,
- requestToken,
- signedTx,
- isLoading,
- error,
- recipient,
- };
-}
-
-export default useSendBtcRequest;
diff --git a/src/app/hooks/useSignBatchPsbtTx.ts b/src/app/hooks/useSignBatchPsbtTx.ts
index 343c54fa4..98248742b 100644
--- a/src/app/hooks/useSignBatchPsbtTx.ts
+++ b/src/app/hooks/useSignBatchPsbtTx.ts
@@ -1,7 +1,8 @@
-import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types';
+import { MESSAGE_SOURCE, SatsConnectMethods } from '@common/types/message-types';
import useWalletSelector from '@hooks/useWalletSelector';
import { InputToSign, signPsbt } from '@secretkeylabs/xverse-core';
import { decodeToken } from 'jsontokens';
+import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { SignMultiplePsbtPayload, SignMultipleTransactionOptions } from 'sats-connect';
import useSeedVault from './useSeedVault';
@@ -12,7 +13,10 @@ const useSignBatchPsbtTx = () => {
const { getSeed } = useSeedVault();
const params = new URLSearchParams(search);
const requestToken = params.get('signBatchPsbtRequest') ?? '';
- const request = decodeToken(requestToken) as any as SignMultipleTransactionOptions;
+ const request = useMemo(
+ () => decodeToken(requestToken) as any as SignMultipleTransactionOptions,
+ [requestToken],
+ );
const tabId = params.get('tabId') ?? '0';
const confirmSignPsbt = async (psbt: SignMultiplePsbtPayload) => {
@@ -36,7 +40,7 @@ const useSignBatchPsbtTx = () => {
const cancelSignPsbt = () => {
const signingMessage = {
source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.signBatchPsbtResponse,
+ method: SatsConnectMethods.signBatchPsbtResponse,
payload: { signBatchPsbtRequest: requestToken, signBatchPsbtResponse: 'cancel' },
};
chrome.tabs.sendMessage(+tabId, signingMessage);
diff --git a/src/app/hooks/useSignPsbtTx.ts b/src/app/hooks/useSignPsbtTx.ts
deleted file mode 100644
index 88412b685..000000000
--- a/src/app/hooks/useSignPsbtTx.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types';
-import useWalletSelector from '@hooks/useWalletSelector';
-import { InputToSign, psbtBase64ToHex, signPsbt } from '@secretkeylabs/xverse-core';
-import { decodeToken } from 'jsontokens';
-import { useLocation } from 'react-router-dom';
-import { SignTransactionOptions } from 'sats-connect';
-import useBtcClient from './useBtcClient';
-import useSeedVault from './useSeedVault';
-
-const useSignPsbtTx = () => {
- const { accountsList, network } = useWalletSelector();
- const { search } = useLocation();
- const { getSeed } = useSeedVault();
- const params = new URLSearchParams(search);
- const requestToken = params.get('signPsbtRequest') ?? '';
- const request = decodeToken(requestToken) as any as SignTransactionOptions;
- const tabId = params.get('tabId') ?? '0';
- const btcClient = useBtcClient();
-
- const confirmSignPsbt = async (signingResponseOverride?: string) => {
- let signingResponse = signingResponseOverride;
-
- if (!signingResponse) {
- const seedPhrase = await getSeed();
- signingResponse = await signPsbt(
- seedPhrase,
- accountsList,
- request.payload.inputsToSign,
- request.payload.psbtBase64,
- request.payload.broadcast,
- network.type,
- );
- }
-
- let txId: string = '';
- if (request.payload.broadcast) {
- const txHex = psbtBase64ToHex(signingResponse);
- const response = await btcClient.sendRawTransaction(txHex);
- txId = response.tx.hash;
- }
- const signingMessage = {
- source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.signPsbtResponse,
- payload: {
- signPsbtRequest: requestToken,
- signPsbtResponse: {
- psbtBase64: signingResponse,
- txId,
- },
- },
- };
- chrome.tabs.sendMessage(+tabId, signingMessage);
- return {
- txId,
- signingResponse,
- };
- };
-
- const cancelSignPsbt = () => {
- const signingMessage = {
- source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.signPsbtResponse,
- payload: { signPsbtRequest: requestToken, signPsbtResponse: 'cancel' },
- };
- chrome.tabs.sendMessage(+tabId, signingMessage);
- };
-
- const getSigningAddresses = (inputsToSign: InputToSign[]) => {
- const signingAddresses: Array = [];
- inputsToSign.forEach((inputToSign) => {
- inputToSign.signingIndexes.forEach((signingIndex) => {
- signingAddresses[signingIndex] = inputToSign.address;
- });
- });
- return signingAddresses;
- };
-
- return {
- payload: request.payload,
- tabId,
- requestToken,
- getSigningAddresses,
- confirmSignPsbt,
- cancelSignPsbt,
- };
-};
-
-export default useSignPsbtTx;
diff --git a/src/app/hooks/useSignatureRequest.ts b/src/app/hooks/useSignatureRequest.ts
index 2463ad6f6..e4faeeb44 100644
--- a/src/app/hooks/useSignatureRequest.ts
+++ b/src/app/hooks/useSignatureRequest.ts
@@ -1,16 +1,17 @@
-import { getStxAddressKeyChain, signBip322Message, signMessage } from '@secretkeylabs/xverse-core';
-import { SignaturePayload } from '@stacks/connect';
+import { getStxAddressKeyChain, signMessage } from '@secretkeylabs/xverse-core';
+import { SignaturePayload, StructuredDataSignatureRequestOptions } from '@stacks/connect';
import {
ChainID,
+ TupleCV,
createStacksPrivateKey,
deserializeCV,
+ hexToCV,
signStructuredData,
- TupleCV,
} from '@stacks/transactions';
import { decodeToken } from 'jsontokens';
-import { useCallback } from 'react';
+import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
-import { SignMessagePayload } from 'sats-connect';
+import useNetworkSelector from './useNetwork';
import useSeedVault from './useSeedVault';
import useWalletSelector from './useWalletSelector';
@@ -25,28 +26,55 @@ export function isUtf8Message(messageType: SignatureMessageType): messageType is
return messageType === 'utf8';
}
-export function isSignBip322Request(
- requestPayload: SignMessagePayload | SignaturePayload,
-): requestPayload is SignMessagePayload {
- return (requestPayload as SignMessagePayload).address !== undefined;
-}
-
function useSignatureRequest() {
const { search } = useLocation();
- const params = new URLSearchParams(search);
- const requestToken = params.get('request') || params.get('signMessageRequest');
- const request = decodeToken(requestToken as string);
- const messageType = params.get('messageType') || '';
+ const params = useMemo(() => new URLSearchParams(search), [search]);
const tabId = params.get('tabId') ?? '0';
+ const requestId = params.get('messageId') ?? '';
+ const { stxPublicKey, stxAddress } = useWalletSelector();
+ const selectedNetwork = useNetworkSelector();
+
+ const { payload, domain, messageType, requestToken } = useMemo(() => {
+ const token = params.get('request') || params.get('signMessageRequest');
+ if (token) {
+ const request = decodeToken(token as string);
+ const type = params.get('messageType') || '';
+ return {
+ payload: request.payload as any, // TODO: fix type error
+ requestToken: token,
+ messageType: type as SignatureMessageType,
+ domain: (request.payload as any).domain // TODO: fix type error
+ ? deserializeCV(Buffer.from((request.payload as any).domain, 'hex')) // TODO: fix type error
+ : null,
+ };
+ }
+ const message = params.get('message') || '';
+ const requestDomain = params.get('domain') || '';
+
+ const innerDomain = requestDomain ? (hexToCV(requestDomain) as TupleCV) : undefined;
+
+ const rpcPayload: SignaturePayload | StructuredDataSignatureRequestOptions = {
+ message,
+ stxAddress,
+ domain: innerDomain,
+ publicKey: stxPublicKey,
+ network: selectedNetwork,
+ };
+ return {
+ payload: rpcPayload,
+ messageType: requestDomain ? 'structured' : ('utf8' as SignatureMessageType),
+ requestToken: null,
+ domain: innerDomain,
+ };
+ }, [params, stxAddress, stxPublicKey, selectedNetwork]);
+
return {
- payload: request.payload as any,
- isSignMessageBip322: isSignBip322Request(request.payload as any),
- request: requestToken as string,
- domain: (request.payload as any).domain // TODO: fix type error
- ? deserializeCV(Buffer.from((request.payload as any).domain, 'hex')) // TODO: fix type error
- : null,
- messageType: messageType as SignatureMessageType,
tabId,
+ requestId,
+ requestToken,
+ payload,
+ domain,
+ messageType,
};
}
@@ -67,7 +95,7 @@ export function useSignMessage(messageType: SignatureMessageType) {
}
if (!domain) throw new Error('Domain is required for structured messages');
const sk = createStacksPrivateKey(privateKey);
- const messageDeserialize = deserializeCV(Buffer.from(message, 'hex'));
+ const messageDeserialize = hexToCV(message);
const signature = signStructuredData({
domain,
message: messageDeserialize,
@@ -82,19 +110,4 @@ export function useSignMessage(messageType: SignatureMessageType) {
);
}
-export function useSignBip322Message(message: string, address: string) {
- const { accountsList, network } = useWalletSelector();
- const { getSeed } = useSeedVault();
- return useCallback(async () => {
- const seedPhrase = await getSeed();
- return signBip322Message({
- accounts: accountsList,
- message,
- signatureAddress: address,
- seedPhrase,
- network: network.type,
- });
- }, []);
-}
-
export default useSignatureRequest;
diff --git a/src/app/hooks/useStxAccountRequest.ts b/src/app/hooks/useStxAccountRequest.ts
new file mode 100644
index 000000000..805bbe3af
--- /dev/null
+++ b/src/app/hooks/useStxAccountRequest.ts
@@ -0,0 +1,109 @@
+import { MESSAGE_SOURCE } from '@common/types/message-types';
+import {
+ sendGetAccountsSuccessResponseMessage,
+ sendUserRejectionMessage,
+} from '@common/utils/rpc/stx/rpcResponseMessages';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { bip32, bip39, bs58 } from '@secretkeylabs/xverse-core';
+import { GAIA_HUB_URL } from '@secretkeylabs/xverse-core/constant';
+import { decodeToken } from 'jsontokens';
+import { useCallback, useMemo } from 'react';
+import { useLocation } from 'react-router-dom';
+import { GetAddressOptions } from 'sats-connect';
+import useSeedVault from './useSeedVault';
+
+const useStxAccountRequest = () => {
+ // Params
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+
+ // Utils
+ const { stxAddress, stxPublicKey, network } = useWalletSelector();
+ const { getSeed } = useSeedVault();
+
+ // Related to WebBTC RPC request
+ const messageId = params.get('messageId') ?? '';
+ const tabId = Number(params.get('tabId')) ?? 0;
+ const rpcMethod = params.get('rpcMethod') ?? '';
+
+ // Legacy
+ const origin = params.get('origin') ?? '';
+ const requestToken = params.get('addressRequest') ?? '';
+ const request = useMemo(
+ () => (requestToken ? (decodeToken(requestToken) as any as GetAddressOptions) : (null as any)),
+ [requestToken],
+ );
+
+ // Actions
+ const approveStxAccountRequest = useCallback(async () => {
+ const seedPhrase = await getSeed();
+ const seed = await bip39.mnemonicToSeed(seedPhrase);
+ const rootNode = bip32.fromSeed(Buffer.from(seed));
+ const identitiesKeychain = rootNode.derivePath(`m/888'/0'`);
+
+ const identityKeychain = identitiesKeychain.deriveHardened(0);
+ const appsKeyBase58 = identityKeychain.deriveHardened(0).toBase58();
+ const appsKeyUint8Array = bs58.decode(appsKeyBase58);
+ const appsKeyHex = Buffer.from(appsKeyUint8Array).toString('hex');
+
+ const addressesResponse = [
+ {
+ address: stxAddress,
+ publicKey: stxPublicKey,
+ gaiaHubUrl: GAIA_HUB_URL,
+ gaiaAppKey: appsKeyHex,
+ },
+ ];
+
+ const response = {
+ addresses: addressesResponse,
+ };
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: 'stx_getAccounts',
+ payload: { addressRequest: requestToken, addressResponse: response },
+ };
+
+ if (rpcMethod === 'stx_getAccounts') {
+ sendGetAccountsSuccessResponseMessage({ tabId, messageId, result: response });
+ return;
+ }
+
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ }, [getSeed, stxAddress, stxPublicKey, requestToken, tabId, messageId, rpcMethod]);
+ const cancelAccountRequest = useCallback(() => {
+ if (rpcMethod === 'stx_getAccounts') {
+ sendUserRejectionMessage({ tabId, messageId });
+ return;
+ }
+
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: 'stx_getAccounts',
+ payload: { addressRequest: requestToken, addressResponse: 'cancel' },
+ };
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ }, [requestToken, tabId]);
+
+ if (rpcMethod === 'stx_getAccounts') {
+ return {
+ payload: { network },
+ tabId,
+ origin,
+ requestToken,
+ approveStxAccountRequest,
+ cancelAccountRequest,
+ };
+ }
+
+ return {
+ payload: request.payload,
+ tabId,
+ origin,
+ requestToken,
+ approveStxAccountRequest,
+ cancelAccountRequest,
+ };
+};
+
+export default useStxAccountRequest;
diff --git a/src/app/hooks/useStxAddressRequest.ts b/src/app/hooks/useStxAddressRequest.ts
new file mode 100644
index 000000000..1af610442
--- /dev/null
+++ b/src/app/hooks/useStxAddressRequest.ts
@@ -0,0 +1,88 @@
+import { MESSAGE_SOURCE } from '@common/types/message-types';
+import { sendGetAddressesSuccessResponseMessage } from '@common/utils/rpc/stx/rpcResponseMessages';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { decodeToken } from 'jsontokens';
+import { useCallback, useMemo } from 'react';
+import { useLocation } from 'react-router-dom';
+import { GetAddressOptions } from 'sats-connect';
+
+const useStxAddressRequest = () => {
+ // Params
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+
+ // Utils
+ const { stxAddress, stxPublicKey, network } = useWalletSelector();
+
+ // Related to WebBTC RPC request
+ const messageId = params.get('messageId') ?? '';
+ const tabId = Number(params.get('tabId')) ?? 0;
+ const rpcMethod = params.get('rpcMethod') ?? '';
+
+ // Legacy
+ const origin = params.get('origin') ?? '';
+ const requestToken = params.get('addressRequest') ?? '';
+ const request = useMemo(
+ () => (requestToken ? (decodeToken(requestToken) as any as GetAddressOptions) : (null as any)),
+ [requestToken],
+ );
+
+ const approveStxAddressRequest = useCallback(() => {
+ const addressesResponse = [
+ {
+ address: stxAddress,
+ publicKey: stxPublicKey,
+ },
+ ];
+
+ const response = {
+ addresses: addressesResponse,
+ };
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: 'stx_getAddresses',
+ payload: { addressRequest: requestToken, addressResponse: response },
+ };
+
+ if (rpcMethod === 'stx_getAddresses') {
+ sendGetAddressesSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: response,
+ });
+ return;
+ }
+
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ }, [stxAddress, stxPublicKey, requestToken, tabId]);
+ const cancelAddressRequest = useCallback(() => {
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: 'stx_getAddresses',
+ payload: { addressRequest: requestToken, addressResponse: 'cancel' },
+ };
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ }, [requestToken, tabId]);
+
+ if (rpcMethod === 'stx_getAddresses') {
+ return {
+ payload: { network },
+ tabId,
+ origin,
+ requestToken,
+ approveStxAddressRequest,
+ cancelAddressRequest,
+ };
+ }
+
+ return {
+ payload: request.payload,
+ tabId,
+ origin,
+ requestToken,
+ approveStxAddressRequest,
+ cancelAddressRequest,
+ };
+};
+
+export default useStxAddressRequest;
diff --git a/src/app/hooks/useStxTransactionRequest.ts b/src/app/hooks/useStxTransactionRequest.ts
deleted file mode 100644
index 3597af09c..000000000
--- a/src/app/hooks/useStxTransactionRequest.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { txPayloadToRequest } from '@secretkeylabs/xverse-core';
-import { deserializeTransaction } from '@stacks/transactions';
-import { decodeToken } from 'jsontokens';
-import { useLocation } from 'react-router-dom';
-
-const useStxTransactionRequest = () => {
- const { search } = useLocation();
- const params = new URLSearchParams(search);
- const requestToken = params.get('request') ?? '';
- const request = decodeToken(requestToken) as any;
- const tabId = params.get('tabId') ?? '0';
- const stacksTransaction = request.payload.txHex
- ? deserializeTransaction(request.payload.txHex!)
- : undefined;
-
- const getPayload = () => {
- if (stacksTransaction) {
- const txPayload = txPayloadToRequest(
- stacksTransaction,
- request.payload.stxAddress,
- request.payload.attachment,
- );
- return {
- ...request.payload,
- ...txPayload,
- };
- }
- return request.payload;
- };
-
- const txPayload = getPayload();
-
- return {
- payload: txPayload,
- stacksTransaction,
- tabId,
- requestToken,
- };
-};
-
-export default useStxTransactionRequest;
diff --git a/src/app/hooks/useTrackMixPanelPageViewed.ts b/src/app/hooks/useTrackMixPanelPageViewed.ts
new file mode 100644
index 000000000..375c76307
--- /dev/null
+++ b/src/app/hooks/useTrackMixPanelPageViewed.ts
@@ -0,0 +1,20 @@
+import { isLedgerAccount } from '@utils/helper';
+import { getMixpanelInstance } from 'app/mixpanelSetup';
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import useWalletSelector from './useWalletSelector';
+
+const useTrackMixPanelPageViewed = (properties?: any, deps: any[] = []) => {
+ const { selectedAccount } = useWalletSelector();
+ const location = useLocation();
+
+ useEffect(() => {
+ getMixpanelInstance('web-extension').track_pageview({
+ path: location.pathname,
+ wallet_type: isLedgerAccount(selectedAccount) ? 'ledger' : 'software',
+ ...properties,
+ });
+ }, deps); // eslint-disable-line react-hooks/exhaustive-deps
+};
+
+export default useTrackMixPanelPageViewed;
diff --git a/src/app/hooks/useWalletReducer.ts b/src/app/hooks/useWalletReducer.ts
index d4fe25249..0d655a994 100644
--- a/src/app/hooks/useWalletReducer.ts
+++ b/src/app/hooks/useWalletReducer.ts
@@ -246,10 +246,11 @@ const useWalletReducer = () => {
}
};
- const createWallet = async (mnemonic?: string) => {
- const wallet = mnemonic
- ? await walletFromSeedPhrase({ mnemonic, index: 0n, network: 'Mainnet' })
- : await newWallet();
+ const createWallet = async () => {
+ const mnemonic = await seedVault.getSeed();
+ // TODO refactor to use createWalletAccount instead, which also adds bns name
+ // and gaiahub config
+ const wallet = await walletFromSeedPhrase({ mnemonic, index: 0n, network: 'Mainnet' });
const account: Account = {
id: 0,
diff --git a/src/app/mixpanelSetup.ts b/src/app/mixpanelSetup.ts
new file mode 100644
index 000000000..a91963d56
--- /dev/null
+++ b/src/app/mixpanelSetup.ts
@@ -0,0 +1,34 @@
+import { MIX_PANEL_EXPLORE_APP_TOKEN, MIX_PANEL_TOKEN } from '@utils/constants';
+import mixpanel, { Mixpanel } from 'mixpanel-browser';
+
+export const mixpanelInstances: Record = {
+ 'web-extension': {
+ token: MIX_PANEL_TOKEN,
+ },
+ 'explore-app': {
+ token: MIX_PANEL_EXPLORE_APP_TOKEN,
+ },
+};
+
+// lazy load the mixpanel instances
+export const getMixpanelInstance = (instanceKey: keyof typeof mixpanelInstances): Mixpanel => {
+ if (mixpanel[instanceKey]) {
+ return mixpanel[instanceKey];
+ }
+
+ const token = mixpanelInstances[instanceKey]?.token;
+ if (!token) {
+ throw new Error(`Mixpanel instance ${instanceKey} token not found`);
+ }
+
+ mixpanel.init(
+ token,
+ {
+ debug: process.env.NODE_ENV === 'development',
+ ip: false,
+ persistence: 'localStorage',
+ },
+ instanceKey,
+ );
+ return mixpanel[instanceKey];
+};
diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx
index 015746a25..a871566a8 100644
--- a/src/app/routes/index.tsx
+++ b/src/app/routes/index.tsx
@@ -1,3 +1,4 @@
+import RequestsRoutes from '@common/utils/route-urls';
import ExtendedScreenContainer from '@components/extendedScreenContainer';
import AuthGuard from '@components/guards/auth';
import OnboardingGuard from '@components/guards/onboarding';
@@ -18,6 +19,8 @@ import ConfirmOrdinalTransaction from '@screens/confirmOrdinalTransaction';
import ConfirmStxTransaction from '@screens/confirmStxTransaction';
import AuthenticationRequest from '@screens/connect/authenticationRequest';
import BtcSelectAddressScreen from '@screens/connect/btcSelectAddressScreen';
+import StxSelectAccountScreen from '@screens/connect/stxSelectAccountScreen';
+import StxSelectAddressScreen from '@screens/connect/stxSelectAddressScreen';
import CreateInscription from '@screens/createInscription';
import CreatePassword from '@screens/createPassword';
import CreateWalletSuccess from '@screens/createWalletSuccess';
@@ -44,9 +47,10 @@ 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';
+// import SendBrc20Screen from '@screens/sendBrc20';
import SendBrc20OneStepScreen from '@screens/sendBrc20OneStep';
import SendBtcScreen from '@screens/sendBtc';
import SendSip10Screen from '@screens/sendFt';
@@ -63,6 +67,7 @@ import FiatCurrencyScreen from '@screens/settings/fiatCurrency';
import LockCountdown from '@screens/settings/lockCountdown';
import PrivacyPreferencesScreen from '@screens/settings/privacyPreferences';
import SignBatchPsbtRequest from '@screens/signBatchPsbtRequest';
+import SignMessageRequest from '@screens/signMessageRequest';
import SignPsbtRequest from '@screens/signPsbtRequest';
import SignatureRequest from '@screens/signatureRequest';
import SpeedUpTransactionScreen from '@screens/speedUpTransaction';
@@ -221,7 +226,7 @@ const router = createHashRouter([
),
},
{
- path: 'btc-select-address-request',
+ path: RequestsRoutes.AddressRequest,
element: (
@@ -229,7 +234,23 @@ const router = createHashRouter([
),
},
{
- path: 'psbt-signing-request',
+ path: 'stx-select-address-request',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'stx-select-account-request',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: RequestsRoutes.SignBtcTx,
element: (
@@ -245,7 +266,7 @@ const router = createHashRouter([
),
},
{
- path: 'btc-send-request',
+ path: RequestsRoutes.SendBtcTx,
element: (
@@ -320,6 +341,10 @@ const router = createHashRouter([
path: 'restore-ordinals',
element: ,
},
+ {
+ path: 'recover-runes',
+ element: ,
+ },
{
path: 'fiat-currency',
element: ,
@@ -361,22 +386,31 @@ const router = createHashRouter([
),
},
{
- path: 'send-ordinal',
+ path: RequestsRoutes.SignMessageRequest,
element: (
-
+
),
},
{
- // TODO deprecate this after brc20 one step ledger support done
- path: 'send-brc20',
+ path: 'send-ordinal',
element: (
-
+
),
},
+ // ENG-4020 - Disable BRC20 Sending on Ledger
+ // {
+ // // TODO deprecate this after brc20 one step ledger support done
+ // path: 'send-brc20',
+ // element: (
+ //
+ //
+ //
+ // ),
+ // },
{
path: 'send-brc20-one-step',
element: (
diff --git a/src/app/screens/backupWallet/index.tsx b/src/app/screens/backupWallet/index.tsx
index 490100ff8..e7734795d 100644
--- a/src/app/screens/backupWallet/index.tsx
+++ b/src/app/screens/backupWallet/index.tsx
@@ -1,7 +1,7 @@
import backup from '@assets/img/backupWallet/backup.svg';
-import ActionButton from '@components/button';
import useSeedVault from '@hooks/useSeedVault';
import { generateMnemonic } from '@secretkeylabs/xverse-core';
+import Button from '@ui-library/button';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -30,14 +30,14 @@ const ContentContainer = styled.div((props) => ({
}));
const Title = styled.h1((props) => ({
- ...props.theme.body_bold_l,
+ ...props.theme.typography.body_bold_l,
textAlign: 'center',
}));
const SubTitle = styled.h2((props) => ({
- ...props.theme.body_l,
+ ...props.theme.typography.body_l,
textAlign: 'center',
- marginTop: props.theme.spacing(4),
+ marginTop: props.theme.space.xs,
color: props.theme.colors.white_200,
}));
@@ -47,11 +47,7 @@ const BackupActionsContainer = styled.div((props) => ({
justifyContent: 'space-between',
marginTop: props.theme.spacing(20),
width: '100%',
-}));
-
-const TransparentButtonContainer = styled.div((props) => ({
- marginRight: props.theme.spacing(8),
- width: '100%',
+ columnGap: props.theme.space.xs,
}));
function BackupWallet(): JSX.Element {
@@ -65,6 +61,7 @@ function BackupWallet(): JSX.Element {
clearVaultStorage,
} = useSeedVault();
+ // TODO move this to SeedVault?
const generateAndStoreSeedPhrase = async () => {
const newSeedPhrase = generateMnemonic();
await initSeedVault('');
@@ -103,10 +100,8 @@ function BackupWallet(): JSX.Element {
{t('SCREEN_TITLE')}
{t('SCREEN_SUBTITLE')}
-
-
-
-
+
+
diff --git a/src/app/screens/backupWalletSteps/index.tsx b/src/app/screens/backupWalletSteps/index.tsx
index d6c7a669c..15547ef67 100644
--- a/src/app/screens/backupWalletSteps/index.tsx
+++ b/src/app/screens/backupWalletSteps/index.tsx
@@ -1,12 +1,12 @@
import { useWalletExistsContext } from '@components/guards/onboarding';
import PasswordInput from '@components/passwordInput';
import Steps from '@components/steps';
+import useSeedVault from '@hooks/useSeedVault';
import useWalletReducer from '@hooks/useWalletReducer';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
-import useSeedVault from '@hooks/useSeedVault';
import SeedCheck from './seedCheck';
import VerifySeed from './verifySeed';
@@ -14,9 +14,9 @@ const Container = styled.div((props) => ({
display: 'flex',
flex: 1,
flexDirection: 'column',
- paddingLeft: props.theme.spacing(8),
- paddingRight: props.theme.spacing(8),
- paddingTop: props.theme.spacing(12),
+ paddingLeft: props.theme.space.m,
+ paddingRight: props.theme.space.m,
+ paddingTop: props.theme.space.l,
}));
const SeedContainer = styled.div((props) => ({
@@ -24,8 +24,8 @@ const SeedContainer = styled.div((props) => ({
}));
const PasswordContainer = styled.div((props) => ({
- marginTop: props.theme.spacing(32),
- marginBottom: props.theme.spacing(32),
+ marginTop: props.theme.space.xxxl,
+ marginBottom: props.theme.space.xxxl,
display: 'flex',
flex: 1,
}));
@@ -51,6 +51,9 @@ export default function BackupWalletSteps(): JSX.Element {
navigate('/backup');
}
})();
+ return () => {
+ setSeedPhrase('');
+ };
}, []);
const handleSeedCheckContinue = () => {
@@ -80,7 +83,7 @@ export default function BackupWalletSteps(): JSX.Element {
const handleConfirmPasswordContinue = async () => {
if (confirmPassword === password) {
disableWalletExistsGuard?.();
- await createWallet(seedPhrase);
+ await createWallet(); // TODO move this somwhere else
await changePassword('', password);
navigate('/wallet-success/create', { replace: true });
} else {
@@ -107,6 +110,7 @@ export default function BackupWalletSteps(): JSX.Element {
handleContinue={handleNewPasswordContinue}
handleBack={handleNewPasswordBack}
checkPasswordStrength
+ autoFocus
/>
,
@@ -118,6 +122,7 @@ export default function BackupWalletSteps(): JSX.Element {
handleContinue={handleConfirmPasswordContinue}
handleBack={handleConfirmPasswordBack}
passwordError={error}
+ autoFocus
/>
,
];
diff --git a/src/app/screens/backupWalletSteps/seedCheck.tsx b/src/app/screens/backupWalletSteps/seedCheck.tsx
index d978841e3..7051dc38f 100644
--- a/src/app/screens/backupWalletSteps/seedCheck.tsx
+++ b/src/app/screens/backupWalletSteps/seedCheck.tsx
@@ -1,12 +1,9 @@
import SeedphraseView from '@components/seedPhraseView';
+import Button from '@ui-library/button';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
-interface ButtonProps {
- enabled: boolean;
-}
-
const Container = styled.div({
display: 'flex',
flexDirection: 'column',
@@ -14,31 +11,19 @@ const Container = styled.div({
});
const Heading = styled.p((props) => ({
- ...props.theme.body_l,
+ ...props.theme.typography.body_l,
color: props.theme.colors.white_200,
marginBottom: props.theme.spacing(20),
}));
const Label = styled.p((props) => ({
- ...props.theme.body_medium_m,
+ ...props.theme.typography.body_medium_m,
color: props.theme.colors.white_0,
marginBottom: props.theme.spacing(4),
}));
-const ContinueButton = styled.button((props) => ({
- display: 'flex',
- ...props.theme.body_bold_m,
- fontSize: 12,
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- borderRadius: props.theme.radius(1),
- backgroundColor: props.theme.colors.action.classic,
- marginBottom: props.theme.spacing(30),
- color: props.theme.colors.elevation0,
- width: '100%',
- height: 44,
- opacity: props.enabled ? 1 : 0.6,
+const ContinueButton = styled(Button)((props) => ({
+ marginBottom: props.theme.space.xxxl,
}));
interface SeedCheckPros {
@@ -57,9 +42,11 @@ export default function SeedCheck(props: SeedCheckPros): JSX.Element {
{showButton && (
-
- {t('SEED_PHRASE_VIEW_CONTINUE')}
-
+
)}
);
diff --git a/src/app/screens/backupWalletSteps/verifySeed.tsx b/src/app/screens/backupWalletSteps/verifySeed.tsx
index e06eb8cd1..a378fbbf9 100644
--- a/src/app/screens/backupWalletSteps/verifySeed.tsx
+++ b/src/app/screens/backupWalletSteps/verifySeed.tsx
@@ -1,4 +1,4 @@
-import ActionButton from '@components/button';
+import Button from '@ui-library/button';
import { generateMnemonic } from 'bip39';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,7 +27,7 @@ const TransparentButtonContainer = styled.div((props) => ({
}));
const Heading = styled.h3((props) => ({
- ...props.theme.body_l,
+ ...props.theme.typography.body_l,
color: props.theme.colors.white_200,
marginBottom: props.theme.spacing(16),
}));
@@ -40,7 +40,7 @@ const WordGrid = styled.div`
`;
const WordButton = styled.button`
- ${(props) => props.theme.body_medium_m};
+ ${(props) => props.theme.typography.body_medium_m};
color: ${(props) => props.theme.colors.white_0};
background-color: ${(props) => props.theme.colors.elevation3};
display: flex;
@@ -48,8 +48,9 @@ const WordButton = styled.button`
align-items: center;
padding: ${(props) => props.theme.spacing(6)}px;
border-radius: ${(props) => props.theme.radius(1)}px;
- transition: all 0.1s ease;
- :hover:enabled {
+ transition: opacity 0.1s ease;
+ :hover:enabled,
+ :focus:enabled {
opacity: 0.8;
}
:active:enabled {
@@ -58,12 +59,12 @@ const WordButton = styled.button`
`;
const NthSpan = styled.span`
- ${(props) => props.theme.body_bold_l};
+ ${(props) => props.theme.typography.body_bold_l};
color: ${(props) => props.theme.colors.white_0};
`;
const ErrorMessage = styled.p<{ visible: boolean }>`
- ${(props) => props.theme.body_s};
+ ${(props) => props.theme.typography.body_s};
color: ${(props) => props.theme.colors.feedback.error};
visibility: ${(props) => (props.visible ? 'initial' : 'hidden')};
`;
@@ -151,7 +152,7 @@ export default function VerifySeed({
{err}
-
+
diff --git a/src/app/screens/btcSendScreen/index.tsx b/src/app/screens/btcSendScreen/index.tsx
index 16cd4f4d8..532b22a1e 100644
--- a/src/app/screens/btcSendScreen/index.tsx
+++ b/src/app/screens/btcSendScreen/index.tsx
@@ -1,4 +1,3 @@
-import useSendBtcRequest from '@hooks/useSendBtcRequest';
import useWalletSelector from '@hooks/useWalletSelector';
import { ErrorCodes, getBtcFiatEquivalent } from '@secretkeylabs/xverse-core';
import Spinner from '@ui-library/spinner';
@@ -8,6 +7,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
+import useSendBtcRequest from './useSendBtcRequest';
const OuterContainer = styled.div`
display: flex;
@@ -22,55 +22,54 @@ const OuterContainer = styled.div`
`;
function BtcSendScreen() {
- const { payload, signedTx, isLoading, tabId, requestToken, error, recipient } =
+ const { payload, signedTx, isLoading, tabId, requestToken, requestId, error, recipient } =
useSendBtcRequest();
const navigate = useNavigate();
- const { btcFiatRate, network, btcAddress } = useWalletSelector();
+ const { btcFiatRate, btcAddress, network } = useWalletSelector();
const { t } = useTranslation('translation');
- const checkIfMismatch = () => {
- if (payload.senderAddress !== btcAddress) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'STX',
- error: t('CONFIRM_TRANSACTION.ADDRESS_MISMATCH'),
- browserTx: true,
- },
- });
- }
- if (payload.network.type !== network.type) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'BTC',
- error: t('CONFIRM_TRANSACTION.NETWORK_MISMATCH'),
- browserTx: true,
- },
- });
- }
- };
-
- const checkIfValidAmount = () => {
- recipient.forEach((txRecipient) => {
- if (txRecipient.amountSats.lt(BITCOIN_DUST_AMOUNT_SATS)) {
+ useEffect(() => {
+ const checkIfMismatch = () => {
+ if (payload.senderAddress !== btcAddress) {
+ navigate('/tx-status', {
+ state: {
+ txid: '',
+ currency: 'STX',
+ error: t('CONFIRM_TRANSACTION.ADDRESS_MISMATCH'),
+ browserTx: true,
+ },
+ });
+ }
+ if (payload.network.type !== network.type) {
navigate('/tx-status', {
state: {
txid: '',
currency: 'BTC',
- error: t('SEND.ERRORS.BELOW_MINIMUM_AMOUNT'),
+ error: t('CONFIRM_TRANSACTION.NETWORK_MISMATCH'),
browserTx: true,
},
});
}
- });
- };
+ };
- useEffect(() => {
checkIfMismatch();
- }, []);
+ }, [payload]);
useEffect(() => {
+ const checkIfValidAmount = () => {
+ recipient.forEach((txRecipient) => {
+ if (txRecipient.amountSats.lt(BITCOIN_DUST_AMOUNT_SATS)) {
+ navigate('/tx-status', {
+ state: {
+ txid: '',
+ currency: 'BTC',
+ error: t('SEND.ERRORS.BELOW_MINIMUM_AMOUNT'),
+ browserTx: true,
+ },
+ });
+ }
+ });
+ };
checkIfValidAmount();
}, [recipient]);
@@ -116,10 +115,12 @@ function BtcSendScreen() {
btcSendBrowserTx: true,
requestToken,
tabId,
+ requestId,
},
});
}
}, [signedTx]);
+
return {isLoading && };
}
diff --git a/src/app/screens/btcSendScreen/useSendBtcRequest.ts b/src/app/screens/btcSendScreen/useSendBtcRequest.ts
new file mode 100644
index 000000000..46dea9439
--- /dev/null
+++ b/src/app/screens/btcSendScreen/useSendBtcRequest.ts
@@ -0,0 +1,103 @@
+import { Recipient, SettingsNetwork, signBtcTransaction } from '@secretkeylabs/xverse-core';
+import { useQuery } from '@tanstack/react-query';
+import BigNumber from 'bignumber.js';
+import { decodeToken } from 'jsontokens';
+import { useMemo, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import {
+ BitcoinNetworkType,
+ SendBtcTransactionOptions,
+ SendBtcTransactionPayload,
+} from 'sats-connect';
+import useBtcClient from '../../hooks/useBtcClient';
+import useSeedVault from '../../hooks/useSeedVault';
+import useWalletSelector from '../../hooks/useWalletSelector';
+
+const useSendBtcRequestParams = (btcAddress: string, network: SettingsNetwork) => {
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+ const tabId = params.get('tabId') ?? '0';
+ const requestId = params.get('requestId') ?? '';
+
+ const { payload, requestToken } = useMemo(() => {
+ const token = params.get('sendBtcRequest') ?? '';
+ if (token) {
+ const request = decodeToken(token) as any as SendBtcTransactionOptions;
+ return {
+ payload: request.payload,
+ requestToken: token,
+ };
+ }
+ const recipients = JSON.parse(params.get('recipients')!);
+ const transferRecipients = recipients?.map((value) => ({
+ address: value.address,
+ amountSats: BigInt(value.amount),
+ }));
+ const rpcPayload: SendBtcTransactionPayload = {
+ senderAddress: btcAddress,
+ recipients: transferRecipients,
+ network:
+ network.type === 'Mainnet'
+ ? { type: BitcoinNetworkType.Mainnet }
+ : { type: BitcoinNetworkType.Testnet },
+ };
+ return {
+ payload: rpcPayload,
+ requestToken: null,
+ };
+ }, []);
+
+ return { payload, tabId, requestToken, requestId };
+};
+
+function useSendBtcRequest() {
+ const btcClient = useBtcClient();
+ const { getSeed } = useSeedVault();
+ const { network, selectedAccount, btcAddress } = useWalletSelector();
+ const { payload, tabId, requestToken, requestId } = useSendBtcRequestParams(btcAddress, network);
+ const [recipient, setRecipient] = useState([]);
+
+ const generateSignedTransaction = async () => {
+ const seedPhrase = await getSeed();
+ const recipients: Recipient[] = [];
+ payload.recipients?.forEach(async (value) => {
+ const txRecipient: Recipient = {
+ address: value.address,
+ amountSats: new BigNumber(value.amountSats.toString()),
+ };
+ recipients.push(txRecipient);
+ });
+ setRecipient(recipients);
+ const signedTx = await signBtcTransaction(
+ recipients,
+ payload.senderAddress || btcAddress,
+ selectedAccount?.id ?? 0,
+ seedPhrase,
+ btcClient,
+ network.type,
+ );
+ return signedTx;
+ };
+
+ const {
+ data: signedTx,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['generate-signed-transaction'],
+ queryFn: generateSignedTransaction,
+ });
+
+ return {
+ payload,
+ tabId,
+ requestToken,
+ requestId,
+ signedTx,
+ isLoading,
+ error,
+ recipient,
+ };
+}
+
+export default useSendBtcRequest;
diff --git a/src/app/screens/coinDashboard/coinHeader.tsx b/src/app/screens/coinDashboard/coinHeader.tsx
index 4d1518d6d..480302612 100644
--- a/src/app/screens/coinDashboard/coinHeader.tsx
+++ b/src/app/screens/coinDashboard/coinHeader.tsx
@@ -429,10 +429,12 @@ export default function CoinHeader(props: CoinBalanceProps) {
{renderStackingBalances()}
-
- goToSendScreen()} />
-
-
+ {/* ENG-4020 - Disable BRC20 Sending on Ledger */}
+ {!(fungibleToken?.protocol === 'brc-20' && isLedgerAccount(selectedAccount)) && (
+
+ goToSendScreen()} />
+
+ )}
{!fungibleToken ? (
<>
diff --git a/src/app/screens/coinDashboard/index.tsx b/src/app/screens/coinDashboard/index.tsx
index 067c98b8b..20322a877 100644
--- a/src/app/screens/coinDashboard/index.tsx
+++ b/src/app/screens/coinDashboard/index.tsx
@@ -6,6 +6,7 @@ import { useVisibleBrc20FungibleTokens } from '@hooks/queries/ordinals/useGetBrc
import { useVisibleRuneFungibleTokens } from '@hooks/queries/runes/useGetRuneFungibleTokens';
import { useVisibleSip10FungibleTokens } from '@hooks/queries/stx/useGetSip10FungibleTokens';
import useBtcWalletData from '@hooks/queries/useBtcWalletData';
+import useTrackMixPanelPageViewed from '@hooks/useTrackMixPanelPageViewed';
import { CurrencyTypes } from '@utils/constants';
import { getExplorerUrl } from '@utils/helper';
import { useState } from 'react';
@@ -116,12 +117,6 @@ export default function CoinDashboard() {
const { visible: brc20CoinsList } = useVisibleBrc20FungibleTokens();
const ftKey = searchParams.get('ftKey');
- useBtcWalletData();
-
- const handleBack = () => {
- navigate(-1);
- };
-
const selectedFt =
sip10CoinsList.find((ft) => ft.principal === ftKey) ??
brc20CoinsList.find((ft) => ft.principal === ftKey) ??
@@ -129,6 +124,19 @@ export default function CoinDashboard() {
const protocol = selectedFt?.protocol;
+ useBtcWalletData();
+ useTrackMixPanelPageViewed(
+ protocol
+ ? {
+ protocol,
+ }
+ : {},
+ );
+
+ const handleBack = () => {
+ navigate(-1);
+ };
+
const openContractDeployment = () =>
window.open(getExplorerUrl(selectedFt?.principal as string), '_blank');
diff --git a/src/app/screens/confirmBtcTransaction/index.tsx b/src/app/screens/confirmBtcTransaction/index.tsx
index 1da484718..ce9985a8f 100644
--- a/src/app/screens/confirmBtcTransaction/index.tsx
+++ b/src/app/screens/confirmBtcTransaction/index.tsx
@@ -1,5 +1,6 @@
import { ConfirmBtcTransactionState, LedgerTransactionType } from '@common/types/ledger';
-import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types';
+import { MESSAGE_SOURCE, SatsConnectMethods } from '@common/types/message-types';
+import { makeRPCError, makeRpcSuccessResponse, sendRpcResponse } from '@common/utils/rpc/helpers';
import AlertMessage from '@components/alertMessage';
import ConfirmBtcTransactionComponent from '@components/confirmBtcTransactionComponent';
import InfoContainer from '@components/infoContainer';
@@ -13,9 +14,10 @@ import { useMutation } from '@tanstack/react-query';
import { isLedgerAccount } from '@utils/helper';
import { saveTimeForNonOrdinalTransferTransaction } from '@utils/localStorage';
import BigNumber from 'bignumber.js';
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
+import { Return, RpcErrorCode } from 'sats-connect';
import SendLayout from '../../layouts/sendLayout';
function ConfirmBtcTransaction() {
@@ -41,6 +43,7 @@ function ConfirmBtcTransaction() {
tabId,
isBrc20TokenFlow,
feePerVByte,
+ requestId,
} = location.state;
if (typeof fee !== 'string' && !BigNumber.isBigNumber(fee)) {
Object.setPrototypeOf(fee, BigNumber.prototype);
@@ -79,45 +82,46 @@ function ConfirmBtcTransaction() {
mutate({ txToBeBroadcasted: signedTx });
};
- useEffect(() => {
- if (errorBtcOrdinalTransaction) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'BTC',
- isNft: true,
- error: errorBtcOrdinalTransaction.toString(),
- },
- });
- }
- }, [errorBtcOrdinalTransaction]);
-
- useEffect(() => {
- if (btcTxBroadcastData) {
- if (btcSendBrowserTx) {
+ const handleBrowserTx = useCallback(
+ (broadCastResult: BtcTransactionBroadcastResponse) => {
+ if (requestToken) {
const btcSendMessage = {
source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.sendBtcResponse,
+ method: SatsConnectMethods.sendBtcResponse,
payload: {
sendBtcRequest: requestToken,
- sendBtcResponse: btcTxBroadcastData.tx.hash,
+ sendBtcResponse: broadCastResult.tx.hash,
},
};
chrome.tabs.sendMessage(+tabId, btcSendMessage);
- window.close();
} else {
- navigate('/tx-status', {
- state: {
- txid: btcTxBroadcastData.tx.hash,
- currency: 'BTC',
- error: '',
- isBrc20TokenFlow,
- },
- });
- setTimeout(() => {
- refetch();
- }, 1000);
+ const result: Return<'sendTransfer'> = {
+ txid: broadCastResult.tx.hash,
+ };
+ const response = makeRpcSuccessResponse(requestId, result);
+ sendRpcResponse(+tabId, response);
}
+ window.close();
+ },
+ [btcTxBroadcastData, requestToken, tabId],
+ );
+
+ useEffect(() => {
+ if (!btcTxBroadcastData) return;
+ if (btcSendBrowserTx) {
+ handleBrowserTx(btcTxBroadcastData);
+ } else {
+ navigate('/tx-status', {
+ state: {
+ txid: btcTxBroadcastData.tx.hash,
+ currency: 'BTC',
+ error: '',
+ isBrc20TokenFlow,
+ },
+ });
+ setTimeout(() => {
+ refetch();
+ }, 1000);
}
}, [btcTxBroadcastData]);
@@ -141,6 +145,13 @@ function ConfirmBtcTransaction() {
useEffect(() => {
if (txError) {
+ if (btcSendBrowserTx) {
+ const errorResponse = makeRPCError(requestId, {
+ code: RpcErrorCode.INTERNAL_ERROR,
+ message: txError.toString(),
+ });
+ sendRpcResponse(+tabId, errorResponse);
+ }
navigate('/tx-status', {
state: {
txid: '',
@@ -184,6 +195,23 @@ function ConfirmBtcTransaction() {
if (isRestoreFundFlow || isBrc20TokenFlow) {
navigate(-1);
} else if (btcSendBrowserTx) {
+ if (requestToken) {
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.sendBtcResponse,
+ payload: {
+ sendBtcRequest: requestToken,
+ sendBtcResponse: 'cancel',
+ },
+ };
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ } else {
+ const cancelError = makeRPCError(requestId as string, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: `User rejected request to send transfer`,
+ });
+ sendRpcResponse(+tabId, cancelError);
+ }
window.close();
} else {
navigate('/send-btc', {
@@ -214,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/confirmOrdinalTransaction/index.tsx b/src/app/screens/confirmOrdinalTransaction/index.tsx
index 886ea5b51..3ce8ad3d8 100644
--- a/src/app/screens/confirmOrdinalTransaction/index.tsx
+++ b/src/app/screens/confirmOrdinalTransaction/index.tsx
@@ -134,10 +134,6 @@ function ConfirmOrdinalTransaction() {
mutate({ signedTx: txHex });
};
- const handleOnCancelClick = () => {
- navigate(-1);
- };
-
useResetUserFlow('/confirm-ordinal-tx');
const handleBackButtonClick = () => {
navigate(-1);
@@ -156,7 +152,7 @@ function ConfirmOrdinalTransaction() {
loadingBroadcastedTx={isLoading}
signedTxHex={signedTxHex}
onConfirmClick={handleOnConfirmClick}
- onCancelClick={handleOnCancelClick}
+ onCancelClick={handleBackButtonClick}
ordinalTxUtxo={ordinalUtxo}
assetDetail={selectedOrdinal ? selectedOrdinal.number.toString() : ''}
currentFee={currentFee}
diff --git a/src/app/screens/confirmStxTransaction/index.tsx b/src/app/screens/confirmStxTransaction/index.tsx
index 24baf7560..e8fe5b664 100644
--- a/src/app/screens/confirmStxTransaction/index.tsx
+++ b/src/app/screens/confirmStxTransaction/index.tsx
@@ -1,5 +1,11 @@
import IconStacks from '@assets/img/dashboard/stx_icon.svg';
import { ConfirmStxTransactionState, LedgerTransactionType } from '@common/types/ledger';
+import {
+ sendInternalErrorMessage,
+ sendSignTransactionSuccessResponseMessage,
+ sendStxTransferSuccessResponseMessage,
+ sendUserRejectionMessage,
+} from '@common/utils/rpc/stx/rpcResponseMessages';
import AccountHeaderComponent from '@components/accountHeader';
import ConfirmStxTransactionComponent from '@components/confirmStxTransactionComponent';
import TransferMemoView from '@components/confirmStxTransactionComponent/transferMemoView';
@@ -9,6 +15,7 @@ import BottomBar from '@components/tabBar';
import TopRow from '@components/topRow';
import TransactionDetailComponent from '@components/transactionDetailComponent';
import finalizeTxSignature from '@components/transactionsRequests/utils';
+import useDelegationState from '@hooks/queries/useDelegationState';
import useStxWalletData from '@hooks/queries/useStxWalletData';
import useNetworkSelector from '@hooks/useNetwork';
import useOnOriginTabClose from '@hooks/useOnTabClosed';
@@ -19,43 +26,86 @@ import {
addressToString,
broadcastSignedTransaction,
buf2hex,
- getStxFiatEquivalent,
isMultiSig,
microstacksToStx,
+ stxToMicrostacks,
} from '@secretkeylabs/xverse-core';
import { MultiSigSpendingCondition, deserializeTransaction } from '@stacks/transactions';
import { useMutation } from '@tanstack/react-query';
+import Callout from '@ui-library/callout';
+import { XVERSE_POOL_ADDRESS } from '@utils/constants';
import { isLedgerAccount } from '@utils/helper';
import BigNumber from 'bignumber.js';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
+import { StxRequests } from 'sats-connect';
import styled from 'styled-components';
const AlertContainer = styled.div((props) => ({
marginTop: props.theme.spacing(12),
}));
+const SpendDelegatedStxWarning = styled(Callout)((props) => ({
+ marginBottom: props.theme.space.m,
+}));
+
function ConfirmStxTransaction() {
const { t } = useTranslation('translation');
- const [fee, setStateFee] = useState(new BigNumber(0));
- const [amount, setAmount] = useState(new BigNumber(0));
- const [fiatAmount, setFiatAmount] = useState(new BigNumber(0));
- const [total, setTotal] = useState(new BigNumber(0));
- const [fiatTotal, setFiatTotal] = useState(new BigNumber(0));
+ const [customFee, setCustomFee] = useState(new BigNumber(0));
const [hasTabClosed, setHasTabClosed] = useState(false);
- const [recipient, setRecipient] = useState('');
const [txRaw, setTxRaw] = useState('');
- const [memo, setMemo] = useState('');
const navigate = useNavigate();
const selectedNetwork = useNetworkSelector();
- const { stxBtcRate, btcFiatRate, network, selectedAccount } = useWalletSelector();
+ const { network, selectedAccount, stxLockedBalance, stxAvailableBalance } = useWalletSelector();
const { refetch } = useStxWalletData();
+ const { data: delegateState } = useDelegationState();
const location = useLocation();
- const { unsignedTx: stringHex, sponsored, isBrowserTx, tabId, requestToken } = location.state;
+ const {
+ unsignedTx: stringHex,
+ sponsored,
+ isBrowserTx,
+ tabId,
+ messageId,
+ rpcMethod,
+ requestToken,
+ } = location.state as {
+ tabId?: chrome.tabs.Tab['id'];
+ messageId?: string;
+ rpcMethod?: keyof StxRequests;
+ [key: string]: any;
+ };
const unsignedTx = useMemo(() => deserializeTransaction(stringHex), [stringHex]);
+ const txPayload = unsignedTx.payload as TokenTransferPayload;
+ const recipient = addressToString(txPayload.recipient.address);
+ const amount = new BigNumber(txPayload.amount.toString(10));
+ const memo = txPayload.memo.content.split('\u0000').join('');
+ const delegatedAmount =
+ delegateState?.delegated &&
+ delegateState.amount &&
+ delegateState.delegatedTo === XVERSE_POOL_ADDRESS
+ ? delegateState.amount
+ : '0';
+
+ const getIsSpendDelegateStx = () => {
+ if (!amount || !recipient) {
+ return false;
+ }
+
+ const hasDelegationNotLocked = BigNumber(delegatedAmount).gt(
+ stxToMicrostacks(BigNumber(1)).plus(stxLockedBalance),
+ );
+ // stacking contract locks 1stx less from what user delegates to let them revoke delegation. counting this doesn't harm cause probably no one will top up just 1stx and min amount to first delegation is 100stx.
+
+ const fee = customFee.gt(0)
+ ? customFee
+ : new BigNumber(unsignedTx?.auth?.spendingCondition?.fee.toString() ?? '0');
+ const total = amount.plus(fee);
+ return hasDelegationNotLocked && BigNumber(total).plus(delegatedAmount).gt(stxAvailableBalance);
+ };
+
// SignTransaction Params
const isMultiSigTx = useMemo(() => isMultiSig(unsignedTx), [unsignedTx]);
const hasSignatures = useMemo(
@@ -80,13 +130,38 @@ function ConfirmStxTransaction() {
});
useEffect(() => {
+ // This useEffect runs when the tx has been broadcasted
if (stxTxBroadcastData) {
if (isBrowserTx) {
- finalizeTxSignature({
- requestPayload: requestToken,
- tabId: Number(tabId),
- data: { txId: stxTxBroadcastData, txRaw },
- });
+ if (tabId && messageId && rpcMethod) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction': {
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: txRaw },
+ });
+ break;
+ }
+ case 'stx_transferStx': {
+ sendStxTransferSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: txRaw, txid: stxTxBroadcastData },
+ });
+ break;
+ }
+ default: {
+ sendInternalErrorMessage({ tabId, messageId });
+ }
+ }
+ } else {
+ finalizeTxSignature({
+ requestPayload: requestToken,
+ tabId: Number(tabId),
+ data: { txId: stxTxBroadcastData, txRaw },
+ });
+ }
}
navigate('/tx-status', {
state: {
@@ -110,55 +185,17 @@ function ConfirmStxTransaction() {
currency: 'STX',
error: txError.toString(),
browserTx: isBrowserTx,
+ tabId,
+ messageId,
},
});
}
}, [txError]);
- const updateUI = () => {
- const txPayload = unsignedTx.payload as TokenTransferPayload;
-
- if (txPayload.recipient.address) {
- setRecipient(addressToString(txPayload.recipient.address));
- }
-
- const txAmount = new BigNumber(txPayload.amount.toString(10));
- const txFee = new BigNumber(unsignedTx.auth.spendingCondition.fee.toString());
- const txTotal = amount.plus(fee);
- const txFiatAmount = getStxFiatEquivalent(
- amount,
- BigNumber(stxBtcRate),
- BigNumber(btcFiatRate),
- );
- const txFiatTotal = getStxFiatEquivalent(amount, BigNumber(stxBtcRate), BigNumber(btcFiatRate));
- const { memo: txMemo } = txPayload;
- // the txPayload returns a string of null bytes incase memo is null
- // remove null bytes so send form treats it as an empty string
- const modifiedMemoString = txMemo.content.split('\u0000').join('');
-
- setAmount(txAmount);
- setStateFee(txFee);
- setFiatAmount(txFiatAmount);
- setTotal(txTotal);
- setFiatTotal(txFiatTotal);
- setMemo(modifiedMemoString);
- };
-
- useEffect(() => {
- if (recipient === '' || !fee || !amount || !fiatAmount || !total || !fiatTotal) {
- updateUI();
- }
- });
-
- const getAmount = () => {
- const txPayload = unsignedTx?.payload as TokenTransferPayload;
- const amountToTransfer = new BigNumber(txPayload?.amount?.toString(10));
- return microstacksToStx(amountToTransfer);
- };
-
const handleConfirmClick = (txs: StacksTransaction[]) => {
if (isLedgerAccount(selectedAccount)) {
const type: LedgerTransactionType = 'STX';
+ const fee = new BigNumber(txs[0].auth.spendingCondition.fee.toString());
const state: ConfirmStxTransactionState = {
unsignedTx: Buffer.from(unsignedTx.serialize()),
type,
@@ -172,12 +209,37 @@ function ConfirmStxTransaction() {
const rawTx = buf2hex(txs[0].serialize());
setTxRaw(rawTx);
if (isMultiSigTx && isBrowserTx) {
- finalizeTxSignature({
- requestPayload: requestToken,
- tabId: Number(tabId),
- // No TxId since the tx was not broadcasted
- data: { txId: '', txRaw: rawTx },
- });
+ // A quick way to infer whether the app is responding to an RPC request.
+ if (tabId && messageId) {
+ switch (rpcMethod) {
+ case 'stx_signTransaction': {
+ sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: rawTx },
+ });
+ break;
+ }
+ case 'stx_transferStx': {
+ sendStxTransferSuccessResponseMessage({
+ tabId,
+ messageId,
+ result: { transaction: txRaw, txid: stxTxBroadcastData ?? '' },
+ });
+ break;
+ }
+ default: {
+ sendInternalErrorMessage({ tabId, messageId });
+ }
+ }
+ } else {
+ finalizeTxSignature({
+ requestPayload: requestToken,
+ tabId: Number(tabId),
+ // No TxId since the tx was not broadcasted
+ data: { txId: '', txRaw: rawTx },
+ });
+ }
window.close();
} else {
mutate({ signedTx: txs[0] });
@@ -186,19 +248,26 @@ function ConfirmStxTransaction() {
const handleCancelClick = () => {
if (isBrowserTx) {
- finalizeTxSignature({ requestPayload: requestToken, tabId: Number(tabId), data: 'cancel' });
+ // A quick way to infer whether the app is responding to an RPC request.
+ if (tabId && messageId) {
+ sendUserRejectionMessage({ tabId, messageId });
+ } else {
+ finalizeTxSignature({ requestPayload: requestToken, tabId: Number(tabId), data: 'cancel' });
+ }
window.close();
} else {
navigate('/send-stx', {
state: {
recipientAddress: recipient,
- amountToSend: getAmount().toString(),
+ amountToSend: microstacksToStx(amount).toString(),
stxMemo: memo,
},
});
}
};
+ const showSpendDelegateStxWarning = getIsSpendDelegateStx();
+
return (
<>
{isBrowserTx ? (
@@ -214,10 +283,14 @@ function ConfirmStxTransaction() {
isSponsored={sponsored}
skipModal={isLedgerAccount(selectedAccount)}
hasSignatures={hasSignatures}
+ onFeeChange={setCustomFee}
>
+ {showSpendDelegateStxWarning && (
+
+ )}
{
+ window.close();
+ },
+ );
} catch (e) {
console.error(e);
} finally {
@@ -228,7 +240,17 @@ function AuthenticationRequest() {
},
method: 'authenticationResponse',
});
- window.close();
+ trackMixPanel(
+ AnalyticsEvents.AppConnected,
+ {
+ requestedAddress: [AddressPurpose.Stacks, AddressPurpose.Payment],
+ wallet_type: selectedAccount?.accountType || 'software',
+ },
+ { send_immediately: true },
+ () => {
+ window.close();
+ },
+ );
} catch (e) {
setIsTxRejected(true);
setIsButtonDisabled(false);
@@ -273,7 +295,7 @@ function AuthenticationRequest() {
/>
diff --git a/src/app/screens/connect/btcSelectAddressScreen/index.styled.ts b/src/app/screens/connect/btcSelectAddressScreen/index.styled.ts
new file mode 100644
index 000000000..7e919a546
--- /dev/null
+++ b/src/app/screens/connect/btcSelectAddressScreen/index.styled.ts
@@ -0,0 +1,68 @@
+import styled from 'styled-components';
+
+export const OuterContainer = styled.div((props) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ paddingLeft: props.theme.space.m,
+ paddingRight: props.theme.space.m,
+ ...props.theme.scrollbar,
+}));
+
+export const HeadingContainer = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 48,
+});
+
+export const AddressBoxContainer = styled.div((props) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ marginTop: props.theme.spacing(12),
+}));
+
+export const TopImage = styled.img((props) => ({
+ maxHeight: 48,
+ borderRadius: props.theme.radius(2),
+ maxWidth: 48,
+ marginBottom: props.theme.space.m,
+}));
+
+export const Title = styled.h1((props) => ({
+ ...props.theme.typography.headline_xs,
+ color: props.theme.colors.white_0,
+}));
+
+export const DapURL = styled.h2((props) => ({
+ ...props.theme.typography.body_medium_m,
+ color: props.theme.colors.white_400,
+ marginTop: props.theme.spacing(2),
+ textAlign: 'center',
+}));
+
+export const RequestMessage = styled.p((props) => ({
+ ...props.theme.typography.body_medium_m,
+ color: props.theme.colors.white_200,
+ textAlign: 'left',
+ wordWrap: 'break-word',
+ marginTop: props.theme.spacing(12),
+ marginBottom: props.theme.spacing(12),
+}));
+
+export const RequestMessagePlaceholder = styled.div((props) => ({
+ marginTop: props.theme.space.l,
+}));
+
+export const PermissionsContainer = styled.div((props) => ({
+ paddingBottom: props.theme.space.xxl,
+}));
+
+export const LoaderContainer = styled.div(() => ({
+ justifySelf: 'center',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flex: 1,
+}));
diff --git a/src/app/screens/connect/btcSelectAddressScreen/index.tsx b/src/app/screens/connect/btcSelectAddressScreen/index.tsx
index d4a6370d3..d59167483 100644
--- a/src/app/screens/connect/btcSelectAddressScreen/index.tsx
+++ b/src/app/screens/connect/btcSelectAddressScreen/index.tsx
@@ -1,84 +1,33 @@
import BitcoinIcon from '@assets/img/dashboard/bitcoin_icon.svg';
import stxIcon from '@assets/img/dashboard/stx_icon.svg';
import OrdinalsIcon from '@assets/img/nftDashboard/white_ordinals_icon.svg';
-import ActionButton from '@components/button';
-import useBtcAddressRequest from '@hooks/useBtcAddressRequest';
import useWalletSelector from '@hooks/useWalletSelector';
import { animated, useTransition } from '@react-spring/web';
import SelectAccount from '@screens/connect/selectAccount';
-import { getAppIconFromWebManifest } from '@secretkeylabs/xverse-core';
+import { AnalyticsEvents, getAppIconFromWebManifest } from '@secretkeylabs/xverse-core';
+import Button from '@ui-library/button';
import { StickyHorizontalSplitButtonContainer } from '@ui-library/common.styled';
import Spinner from '@ui-library/spinner';
+import { trackMixPanel } from '@utils/mixpanel';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { AddressPurpose } from 'sats-connect';
-import styled from 'styled-components';
import AddressPurposeBox from '../addressPurposeBox';
import PermissionsList from '../permissionsList';
-
-const OuterContainer = styled.div((props) => ({
- display: 'flex',
- flexDirection: 'column',
- paddingLeft: props.theme.space.m,
- paddingRight: props.theme.space.m,
- ...props.theme.scrollbar,
-}));
-
-const HeadingContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'center',
- marginTop: 48,
-});
-
-const AddressBoxContainer = styled.div((props) => ({
- display: 'flex',
- flexDirection: 'column',
- width: '100%',
- marginTop: props.theme.spacing(12),
-}));
-
-const TopImage = styled.img((props) => ({
- maxHeight: 48,
- borderRadius: props.theme.radius(2),
- maxWidth: 48,
- marginBottom: props.theme.space.m,
-}));
-
-const Title = styled.h1((props) => ({
- ...props.theme.typography.headline_xs,
- color: props.theme.colors.white_0,
-}));
-
-const DapURL = styled.h2((props) => ({
- ...props.theme.typography.body_medium_m,
- color: props.theme.colors.white_400,
- marginTop: props.theme.spacing(2),
- textAlign: 'center',
-}));
-
-const RequestMessage = styled.p((props) => ({
- ...props.theme.typography.body_medium_m,
- color: props.theme.colors.white_200,
- textAlign: 'left',
- wordWrap: 'break-word',
- marginTop: props.theme.spacing(12),
- marginBottom: props.theme.spacing(12),
-}));
-
-const PermissionsContainer = styled.div((props) => ({
- paddingBottom: props.theme.space.xxl,
-}));
-
-const LoaderContainer = styled.div(() => ({
- justifySelf: 'center',
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- flex: 1,
-}));
+import {
+ AddressBoxContainer,
+ DapURL,
+ HeadingContainer,
+ LoaderContainer,
+ OuterContainer,
+ PermissionsContainer,
+ RequestMessage,
+ RequestMessagePlaceholder,
+ Title,
+ TopImage,
+} from './index.styled';
+import useBtcAddressRequest from './useBtcAddressRequest';
function BtcSelectAddressScreen() {
const [loading, setLoading] = useState(false);
@@ -87,6 +36,7 @@ function BtcSelectAddressScreen() {
const { network, btcAddress, ordinalsAddress, stxAddress, selectedAccount } = useWalletSelector();
const [appIcon, setAppIcon] = useState('');
const [isLoadingIcon, setIsLoadingIcon] = useState(false);
+
const { payload, origin, approveBtcAddressRequest, cancelAddressRequest } =
useBtcAddressRequest();
const appUrl = useMemo(() => origin.replace(/(^\w+:|^)\/\//, ''), [origin]);
@@ -99,7 +49,17 @@ function BtcSelectAddressScreen() {
const confirmCallback = async () => {
setLoading(true);
approveBtcAddressRequest();
- window.close();
+ trackMixPanel(
+ AnalyticsEvents.AppConnected,
+ {
+ requestedAddress: payload.purposes,
+ wallet_type: selectedAccount?.accountType || 'software',
+ },
+ { send_immediately: true },
+ () => {
+ window.close();
+ },
+ );
};
const cancelCallback = () => {
@@ -141,30 +101,25 @@ function BtcSelectAddressScreen() {
}, []);
useEffect(() => {
- (async () => {
- if (origin !== '') {
- setIsLoadingIcon(true);
- getAppIconFromWebManifest(origin)
- .then((appIcons) => {
- setAppIcon(appIcons);
- setIsLoadingIcon(false);
- })
- .catch(() => {
- setIsLoadingIcon(false);
- setAppIcon('');
- });
- }
- })();
+ if (origin === '') {
+ return;
+ }
- return () => {
- setAppIcon('');
- };
+ setIsLoadingIcon(true);
+ getAppIconFromWebManifest(origin)
+ .then((manifestAppIcon) => {
+ setAppIcon(manifestAppIcon);
+ })
+ .finally(() => {
+ setIsLoadingIcon(false);
+ });
}, [origin]);
const AddressPurposeRow = useCallback((purpose: AddressPurpose) => {
if (purpose === AddressPurpose.Payment) {
return (
-
-
- ) : (
+ if (isLoadingIcon) {
+ return (
+
+
+
+ );
+ }
+
+ return (
{transition((style) => (
@@ -214,19 +175,17 @@ function BtcSelectAddressScreen() {
{payload.message ? (
{payload.message.substring(0, 80)}
- ) : null}
+ ) : (
+
+ )}
{payload.purposes.map(AddressPurposeRow)}
-
-
+
+
))}
diff --git a/src/app/screens/connect/btcSelectAddressScreen/useBtcAddressRequest.ts b/src/app/screens/connect/btcSelectAddressScreen/useBtcAddressRequest.ts
new file mode 100644
index 000000000..3a1a80ae2
--- /dev/null
+++ b/src/app/screens/connect/btcSelectAddressScreen/useBtcAddressRequest.ts
@@ -0,0 +1,161 @@
+import { MESSAGE_SOURCE, SatsConnectMethods } from '@common/types/message-types';
+import { makeRPCError, makeRpcSuccessResponse, sendRpcResponse } from '@common/utils/rpc/helpers';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { SettingsNetwork } from '@secretkeylabs/xverse-core';
+import { decodeToken } from 'jsontokens';
+import { useMemo } from 'react';
+import { useLocation } from 'react-router-dom';
+import {
+ Address,
+ AddressPurpose,
+ AddressType,
+ BitcoinNetworkType,
+ GetAddressOptions,
+ GetAddressPayload,
+ GetAddressResponse,
+ Return,
+ RpcErrorCode,
+} from 'sats-connect';
+
+const useAddressRequestParams = (network: SettingsNetwork) => {
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+ const tabId = params.get('tabId') ?? '0';
+ const origin = params.get('origin') ?? '';
+ const requestId = params.get('requestId') ?? '';
+ const rpcMethod = params.get('rpcMethod') ?? '';
+
+ const { payload, requestToken } = useMemo(() => {
+ const token = params.get('addressRequest') ?? '';
+ if (token) {
+ const request = decodeToken(token) as any as GetAddressOptions;
+
+ return {
+ payload: request.payload,
+ requestToken: token,
+ };
+ }
+ const pArray = params.get('purposes');
+ const message = params.get('message') ?? '';
+ const purposes = pArray?.split(',') as AddressPurpose[];
+ if (rpcMethod === 'getAddresses' || rpcMethod === 'getAccounts') {
+ const getAddressRpcPayload: GetAddressPayload = {
+ message,
+ purposes,
+ network:
+ network.type === 'Mainnet'
+ ? {
+ type: BitcoinNetworkType.Mainnet,
+ }
+ : {
+ type: BitcoinNetworkType.Testnet,
+ },
+ };
+ return {
+ payload: getAddressRpcPayload,
+ requestToken: null,
+ };
+ }
+ return {
+ payload: {} as GetAddressPayload,
+ requestToken: {},
+ };
+ }, []);
+
+ return { tabId, origin, payload, requestToken, requestId, rpcMethod };
+};
+
+const useBtcAddressRequest = () => {
+ const {
+ btcAddress,
+ ordinalsAddress,
+ btcPublicKey,
+ ordinalsPublicKey,
+ stxAddress,
+ stxPublicKey,
+ selectedAccount,
+ network,
+ } = useWalletSelector();
+ const { tabId, origin, payload, requestToken, requestId, rpcMethod } =
+ useAddressRequestParams(network);
+
+ const approveBtcAddressRequest = () => {
+ const addressesResponse: Address[] = payload.purposes.map((purpose: AddressPurpose) => {
+ if (purpose === AddressPurpose.Ordinals) {
+ return {
+ address: ordinalsAddress,
+ publicKey: ordinalsPublicKey,
+ purpose: AddressPurpose.Ordinals,
+ addressType: AddressType.p2tr,
+ };
+ }
+ if (purpose === AddressPurpose.Stacks) {
+ return {
+ address: stxAddress,
+ publicKey: stxPublicKey,
+ purpose: AddressPurpose.Stacks,
+ addressType: AddressType.stacks,
+ };
+ }
+ return {
+ address: btcAddress,
+ publicKey: btcPublicKey,
+ purpose: AddressPurpose.Payment,
+ addressType:
+ selectedAccount?.accountType === 'ledger' ? AddressType.p2wpkh : AddressType.p2sh,
+ };
+ });
+ if (requestToken) {
+ const response: GetAddressResponse = {
+ addresses: addressesResponse,
+ };
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.getAddressResponse,
+ payload: { addressRequest: requestToken, addressResponse: response },
+ };
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ } else {
+ if (rpcMethod === 'getAccounts') {
+ const result: Return<'getAccounts'> = addressesResponse;
+ const response = makeRpcSuccessResponse(requestId, result);
+ sendRpcResponse(+tabId, response);
+ }
+ if (rpcMethod === 'getAddresses') {
+ const result: Return<'getAddresses'> = {
+ addresses: addressesResponse,
+ };
+ const response = makeRpcSuccessResponse(requestId, result);
+ sendRpcResponse(+tabId, response);
+ }
+ }
+ };
+
+ const cancelAddressRequest = () => {
+ if (requestToken) {
+ const addressMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.getAddressResponse,
+ payload: { addressRequest: requestToken, addressResponse: 'cancel' },
+ };
+ chrome.tabs.sendMessage(+tabId, addressMessage);
+ } else {
+ const cancelError = makeRPCError(requestId as string, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: `User rejected ${rpcMethod === 'getAddresses' ? 'address' : 'accounts'} request`,
+ });
+ sendRpcResponse(+tabId, cancelError);
+ }
+ };
+
+ return {
+ payload,
+ tabId,
+ origin,
+ requestToken,
+ approveBtcAddressRequest,
+ cancelAddressRequest,
+ };
+};
+
+export default useBtcAddressRequest;
diff --git a/src/app/screens/connect/stxSelectAccountScreen/index.tsx b/src/app/screens/connect/stxSelectAccountScreen/index.tsx
new file mode 100644
index 000000000..0fab995bd
--- /dev/null
+++ b/src/app/screens/connect/stxSelectAccountScreen/index.tsx
@@ -0,0 +1,136 @@
+import stxIcon from '@assets/img/dashboard/stx_icon.svg';
+import ActionButton from '@components/button';
+import useStxAccountRequest from '@hooks/useStxAccountRequest';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { animated, useTransition } from '@react-spring/web';
+import SelectAccount from '@screens/connect/selectAccount';
+import { getAppIconFromWebManifest } from '@secretkeylabs/xverse-core';
+import { StickyHorizontalSplitButtonContainer } from '@ui-library/common.styled';
+import Spinner from '@ui-library/spinner';
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { AddressPurpose } from 'sats-connect';
+import AddressPurposeBox from '../addressPurposeBox';
+import {
+ AddressBoxContainer,
+ DapURL,
+ HeadingContainer,
+ LoaderContainer,
+ OuterContainer,
+ PermissionsContainer,
+ RequestMessage,
+ Title,
+ TopImage,
+} from '../btcSelectAddressScreen/index.styled';
+import PermissionsList from '../permissionsList';
+
+function StxSelectAccountScreen() {
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { t } = useTranslation('translation', { keyPrefix: 'SELECT_BTC_ADDRESS_SCREEN' });
+ const { network, stxAddress, selectedAccount } = useWalletSelector();
+ const [appIcon, setAppIcon] = useState('');
+ const [isLoadingIcon, setIsLoadingIcon] = useState(false);
+ const { payload, origin, approveStxAccountRequest, cancelAccountRequest } =
+ useStxAccountRequest();
+ const appUrl = useMemo(() => origin.replace(/(^\w+:|^)\/\//, ''), [origin]);
+
+ const transition = useTransition(isLoadingIcon, {
+ from: { opacity: 0, y: 30 },
+ enter: { opacity: 1, y: 0 },
+ });
+
+ const confirmCallback = async () => {
+ setLoading(true);
+ await approveStxAccountRequest();
+ window.close();
+ };
+
+ const cancelCallback = () => {
+ cancelAccountRequest();
+ window.close();
+ };
+
+ useEffect(() => {
+ // Handle address requests to a network that's not currently active
+ if (payload.network.type !== network.type) {
+ navigate('/tx-status', {
+ state: {
+ txid: '',
+ currency: 'STX',
+ errorTitle: t('NETWORK_MISMATCH_ERROR_TITLE'),
+ error: t('NETWORK_MISMATCH_ERROR_DESCRIPTION'),
+ browserTx: true,
+ },
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ if (origin === '') {
+ return;
+ }
+
+ setIsLoadingIcon(true);
+ getAppIconFromWebManifest(origin)
+ .then((manifestAppIcon) => {
+ setAppIcon(manifestAppIcon);
+ })
+ .finally(() => {
+ setIsLoadingIcon(false);
+ });
+ }, [origin]);
+
+ const handleSwitchAccount = () => {
+ navigate('/account-list?hideListActions=true');
+ };
+
+ if (isLoadingIcon) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {transition((style) => (
+
+
+ {appIcon !== '' ? : null}
+ {t('TITLE')}
+ {appUrl}
+
+ {payload.message ? (
+ {payload.message.substring(0, 80)}
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export default StxSelectAccountScreen;
diff --git a/src/app/screens/connect/stxSelectAddressScreen/index.tsx b/src/app/screens/connect/stxSelectAddressScreen/index.tsx
new file mode 100644
index 000000000..1c8613945
--- /dev/null
+++ b/src/app/screens/connect/stxSelectAddressScreen/index.tsx
@@ -0,0 +1,136 @@
+import stxIcon from '@assets/img/dashboard/stx_icon.svg';
+import ActionButton from '@components/button';
+import useStxAddressRequest from '@hooks/useStxAddressRequest';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { animated, useTransition } from '@react-spring/web';
+import SelectAccount from '@screens/connect/selectAccount';
+import { getAppIconFromWebManifest } from '@secretkeylabs/xverse-core';
+import { StickyHorizontalSplitButtonContainer } from '@ui-library/common.styled';
+import Spinner from '@ui-library/spinner';
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { AddressPurpose } from 'sats-connect';
+import AddressPurposeBox from '../addressPurposeBox';
+import {
+ AddressBoxContainer,
+ DapURL,
+ HeadingContainer,
+ LoaderContainer,
+ OuterContainer,
+ PermissionsContainer,
+ RequestMessage,
+ Title,
+ TopImage,
+} from '../btcSelectAddressScreen/index.styled';
+import PermissionsList from '../permissionsList';
+
+function StxSelectAddressScreen() {
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { t } = useTranslation('translation', { keyPrefix: 'SELECT_BTC_ADDRESS_SCREEN' });
+ const { network, stxAddress, selectedAccount } = useWalletSelector();
+ const [appIcon, setAppIcon] = useState('');
+ const [isLoadingIcon, setIsLoadingIcon] = useState(false);
+ const { payload, origin, approveStxAddressRequest, cancelAddressRequest } =
+ useStxAddressRequest();
+ const appUrl = useMemo(() => origin.replace(/(^\w+:|^)\/\//, ''), [origin]);
+
+ const transition = useTransition(isLoadingIcon, {
+ from: { opacity: 0, y: 30 },
+ enter: { opacity: 1, y: 0 },
+ });
+
+ const confirmCallback = async () => {
+ setLoading(true);
+ approveStxAddressRequest();
+ window.close();
+ };
+
+ const cancelCallback = () => {
+ cancelAddressRequest();
+ window.close();
+ };
+
+ useEffect(() => {
+ // Handle address requests to a network that's not currently active
+ if (payload.network.type !== network.type) {
+ navigate('/tx-status', {
+ state: {
+ txid: '',
+ currency: 'STX',
+ errorTitle: t('NETWORK_MISMATCH_ERROR_TITLE'),
+ error: t('NETWORK_MISMATCH_ERROR_DESCRIPTION'),
+ browserTx: true,
+ },
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ if (origin === '') {
+ return;
+ }
+
+ setIsLoadingIcon(true);
+ getAppIconFromWebManifest(origin)
+ .then((manifestAppIcon) => {
+ setAppIcon(manifestAppIcon);
+ })
+ .finally(() => {
+ setIsLoadingIcon(false);
+ });
+ }, [origin]);
+
+ const handleSwitchAccount = () => {
+ navigate('/account-list?hideListActions=true');
+ };
+
+ if (isLoadingIcon) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {transition((style) => (
+
+
+ {appIcon !== '' ? : null}
+ {t('TITLE')}
+ {appUrl}
+
+ {payload.message ? (
+ {payload.message.substring(0, 80)}
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export default StxSelectAddressScreen;
diff --git a/src/app/screens/createInscription/index.tsx b/src/app/screens/createInscription/index.tsx
index 980ac9016..e6a4a064c 100644
--- a/src/app/screens/createInscription/index.tsx
+++ b/src/app/screens/createInscription/index.tsx
@@ -21,7 +21,7 @@ import { CreateInscriptionPayload, CreateRepeatInscriptionsPayload } from 'sats-
import SettingIcon from '@assets/img/dashboard/faders_horizontal.svg';
import OrdinalsIcon from '@assets/img/nftDashboard/white_ordinals_icon.svg';
-import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types';
+import { MESSAGE_SOURCE, SatsConnectMethods } from '@common/types/message-types';
import AccountHeaderComponent from '@components/accountHeader';
import ConfirmScreen from '@components/confirmScreen';
import useWalletSelector from '@hooks/useWalletSelector';
@@ -356,8 +356,8 @@ function CreateInscription() {
const response = {
source: MESSAGE_SOURCE,
method: repeat
- ? ExternalSatsMethods.createRepeatInscriptionsResponse
- : ExternalSatsMethods.createInscriptionResponse,
+ ? SatsConnectMethods.createRepeatInscriptionsResponse
+ : SatsConnectMethods.createInscriptionResponse,
payload: {
createInscriptionRequest: requestToken,
createInscriptionResponse: 'cancel',
@@ -420,8 +420,8 @@ function CreateInscription() {
const response = {
source: MESSAGE_SOURCE,
method: repeat
- ? ExternalSatsMethods.createRepeatInscriptionsResponse
- : ExternalSatsMethods.createInscriptionResponse,
+ ? SatsConnectMethods.createRepeatInscriptionsResponse
+ : SatsConnectMethods.createInscriptionResponse,
payload: repeat
? {
createRepeatInscriptionsRequest: requestToken,
diff --git a/src/app/screens/createPassword/index.tsx b/src/app/screens/createPassword/index.tsx
index 88c82b4b1..ebb577496 100644
--- a/src/app/screens/createPassword/index.tsx
+++ b/src/app/screens/createPassword/index.tsx
@@ -7,9 +7,6 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
-interface StepDotProps {
- active: boolean;
-}
const Container = styled.div((props) => ({
display: 'flex',
flex: 1,
@@ -31,7 +28,9 @@ const PasswordContainer = styled.div((props) => ({
marginTop: props.theme.spacing(32),
}));
-const StepDot = styled.div((props) => ({
+const StepDot = styled.div<{
+ active: boolean;
+}>((props) => ({
width: 8,
height: 8,
borderRadius: 4,
@@ -39,6 +38,7 @@ const StepDot = styled.div((props) => ({
marginRight: props.theme.spacing(4),
}));
+// TODO refactor to delete this whole screen and use the backup steps screen instead
function CreatePassword(): JSX.Element {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -49,7 +49,7 @@ function CreatePassword(): JSX.Element {
const { t } = useTranslation('translation', { keyPrefix: 'CREATE_PASSWORD_SCREEN' });
const { createWallet } = useWalletReducer();
const { disableWalletExistsGuard } = useWalletExistsContext();
- const { getSeed, changePassword } = useSeedVault();
+ const { changePassword } = useSeedVault();
const handleContinuePasswordCreation = () => {
setCurrentStepIndex(1);
@@ -60,8 +60,7 @@ function CreatePassword(): JSX.Element {
try {
setIsCreatingWallet(true);
disableWalletExistsGuard?.();
- const seedPhrase = await getSeed();
- await createWallet(seedPhrase);
+ await createWallet(); // TODO move this somwhere else
await changePassword('', password);
navigate('/wallet-success/create', { replace: true });
} catch (err) {
@@ -101,6 +100,7 @@ function CreatePassword(): JSX.Element {
handleBack={handleNewPasswordBack}
checkPasswordStrength
createPasswordFlow
+ autoFocus
/>
) : (
)}
diff --git a/src/app/screens/createWalletSuccess/index.tsx b/src/app/screens/createWalletSuccess/index.tsx
index 1c2441d2e..38456b383 100644
--- a/src/app/screens/createWalletSuccess/index.tsx
+++ b/src/app/screens/createWalletSuccess/index.tsx
@@ -2,6 +2,7 @@ import CheckCircle from '@assets/img/createWalletSuccess/CheckCircle.svg';
import Extension from '@assets/img/createWalletSuccess/extension.svg';
import Logo from '@assets/img/createWalletSuccess/logo.svg';
import Pin from '@assets/img/createWalletSuccess/pin.svg';
+import Button from '@ui-library/button';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
@@ -29,7 +30,7 @@ const RowContainer = styled.div({
});
const InstructionsText = styled.h1((props) => ({
- ...props.theme.body_medium_l,
+ ...props.theme.typography.body_medium_l,
color: 'rgba(255, 255, 255, 0.7)',
}));
@@ -56,29 +57,16 @@ const Title = styled.h1((props) => ({
}));
const Subtitle = styled.h2((props) => ({
- ...props.theme.body_m,
+ ...props.theme.typography.body_m,
color: props.theme.colors.white_400,
marginTop: props.theme.spacing(8),
textAlign: 'center',
}));
-const ContinueButton = styled.button((props) => ({
- ...props.theme.body_bold_m,
- color: props.theme.colors.elevation0,
- backgroundColor: props.theme.colors.action.classic,
- borderRadius: props.theme.radius(1),
- marginLeft: props.theme.spacing(8),
- marginRight: props.theme.spacing(8),
- marginBottom: props.theme.spacing(30),
- height: 44,
- textAlign: 'center',
- ':hover': {
- background: props.theme.colors.action.classicLight,
- },
- ':focus': {
- background: props.theme.colors.action.classicLight,
- opacity: 0.6,
- },
+const ContinueButton = styled(Button)((props) => ({
+ width: `calc(100% - ${props.theme.space.xl})`,
+ margin: '0 auto',
+ marginBottom: props.theme.space.xxxl,
}));
function CreateWalletSuccess(): JSX.Element {
@@ -98,7 +86,7 @@ function CreateWalletSuccess(): JSX.Element {
{action === 'restore' ? t('RESTORE_SCREEN_SUBTITLE') : t('SCREEN_SUBTITLE')}
- {t('CLOSE_TAB')}
+
{`1. ${t('CLICK')}`}
diff --git a/src/app/screens/explore/index.tsx b/src/app/screens/explore/index.tsx
index ae581083f..89c309e7c 100644
--- a/src/app/screens/explore/index.tsx
+++ b/src/app/screens/explore/index.tsx
@@ -2,7 +2,7 @@ import FeaturedCardCarousel from '@components/explore/FeaturedCarousel';
import RecommendedApps from '@components/explore/RecommendedApps';
import SwiperNavigation from '@components/explore/SwiperNavigation';
import BottomBar from '@components/tabBar';
-import useFeaturedDapps from '@hooks/useFeaturedApps';
+import useFeaturedDapps from '@hooks/useFeaturedDapps';
import { ArrowsOut } from '@phosphor-icons/react';
import { StyledHeading } from '@ui-library/common.styled';
import Spinner from '@ui-library/spinner';
@@ -40,6 +40,15 @@ const ExternalLink = styled.a`
color: ${({ theme }) => theme.colors.white_0};
margin-top: ${({ theme }) => theme.space.s};
cursor: pointer;
+ transition: opacity 0.1s ease;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:active {
+ opacity: 0.6;
+ }
`;
const LoaderContainer = styled.div((props) => ({
diff --git a/src/app/screens/home/balanceCard/index.tsx b/src/app/screens/home/balanceCard/index.tsx
index 04290ef23..65c728c63 100644
--- a/src/app/screens/home/balanceCard/index.tsx
+++ b/src/app/screens/home/balanceCard/index.tsx
@@ -56,14 +56,10 @@ const BalanceContainer = styled.div((props) => ({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
- alignItems: 'flex-end',
+ alignItems: 'center',
gap: props.theme.spacing(5),
}));
-const ReloadContainer = styled.div({
- marginBottom: 11,
-});
-
interface BalanceCardProps {
isLoading: boolean;
isRefetching: boolean;
@@ -108,6 +104,26 @@ function BalanceCard(props: BalanceCardProps) {
}
}, [balance, oldTotalBalance, selectedAccount, isLoading, isRefetching]);
+ useEffect(() => {
+ (() => {
+ const balanceEl = document.getElementById('balance');
+
+ if (!balanceEl || !balanceEl.parentElement) {
+ return;
+ }
+
+ const fontSize = balanceEl.style.fontSize ? parseInt(balanceEl.style.fontSize, 10) : 42;
+
+ for (let i = fontSize; i >= 0; i--) {
+ // 26 is loading icon + padding
+ const overflow = balanceEl.clientWidth + 26 > balanceEl.parentElement.clientWidth;
+ if (overflow) {
+ balanceEl.style.fontSize = `${i}px`;
+ }
+ }
+ })();
+ });
+
return (
<>
@@ -127,12 +143,14 @@ function BalanceCard(props: BalanceCardProps) {
displayType="text"
prefix={`${currencySymbolMap[fiatCurrency]}`}
thousandSeparator
- renderText={(value: string) => {value}}
+ renderText={(value: string) => (
+ {value}
+ )}
/>
{isRefetching && (
-
+
-
+
)}
)}
diff --git a/src/app/screens/home/banner.tsx b/src/app/screens/home/banner.tsx
new file mode 100644
index 000000000..5c22e8a1f
--- /dev/null
+++ b/src/app/screens/home/banner.tsx
@@ -0,0 +1,110 @@
+import { XCircle } from '@phosphor-icons/react';
+import { NotificationBanner } from '@secretkeylabs/xverse-core';
+import { setNotificationBannersAction } from '@stores/wallet/actions/actionCreators';
+import { trackMixPanel } from '@utils/mixpanel';
+import { useDispatch } from 'react-redux';
+import styled from 'styled-components';
+import Theme from 'theme';
+
+const Container = styled.div`
+ position: relative;
+ width: 100%;
+ color: ${({ theme }) => theme.colors.white_0};
+ padding-top: ${({ theme }) => theme.space.xxs};
+ padding-bottom: ${({ theme }) => theme.space.m};
+`;
+
+const BannerContent = styled.div`
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.space.m};
+ padding-left: ${({ theme }) => theme.space.m};
+ padding-right: ${({ theme }) => theme.space.m};
+ transition: opacity 0.1s ease;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:active {
+ opacity: 0.6;
+ }
+`;
+
+const BannerImage = styled.img`
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+`;
+
+const BannerTitle = styled.div`
+ ${({ theme }) => theme.typography.body_bold_m};
+`;
+
+const BannerText = styled.div`
+ ${({ theme }) => theme.typography.body_medium_m};
+ color: ${({ theme }) => theme.colors.white_200};
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+`;
+
+const CrossButton = styled.div`
+ z-index: 1;
+ cursor: pointer;
+ position: absolute;
+ top: -${(props) => props.theme.space.xs};
+ right: -${(props) => props.theme.space.xxs};
+ display: flex;
+ transition: opacity 0.1s ease;
+
+ &:hover {
+ opacity: 0.8;
+ }
+
+ &:active {
+ opacity: 0.6;
+ }
+`;
+
+function Banner({ id, name, url, icon, description }: NotificationBanner) {
+ const dispatch = useDispatch();
+
+ const dismissBanner = () => {
+ dispatch(setNotificationBannersAction({ id, isDismissed: true }));
+ };
+
+ return (
+
+
+
+
+ {
+ trackMixPanel(
+ 'click_app',
+ {
+ link: url,
+ source: 'web-extension',
+ },
+ { send_immediately: true },
+ () => {
+ window.open(url, '_blank');
+ },
+ 'explore-app',
+ );
+ }}
+ >
+
+
+ {name}
+ {description}
+
+
+
+ );
+}
+
+export default Banner;
diff --git a/src/app/screens/home/coinSelectModal/index.tsx b/src/app/screens/home/coinSelectModal/index.tsx
index 06a7cc191..0c10b9395 100644
--- a/src/app/screens/home/coinSelectModal/index.tsx
+++ b/src/app/screens/home/coinSelectModal/index.tsx
@@ -81,6 +81,7 @@ function CoinSelectModal({
onClose();
}}
fungibleToken={coin}
+ showProtocolIcon={false}
/>
))}
diff --git a/src/app/screens/home/index.tsx b/src/app/screens/home/index.tsx
index bcfe8b08d..861f3e1e0 100644
--- a/src/app/screens/home/index.tsx
+++ b/src/app/screens/home/index.tsx
@@ -1,8 +1,8 @@
import dashboardIcon from '@assets/img/dashboard-icon.svg';
-import SIP10Icon from '@assets/img/dashboard/SIP10.svg';
import BitcoinToken from '@assets/img/dashboard/bitcoin_token.svg';
import ListDashes from '@assets/img/dashboard/list_dashes.svg';
import ordinalsIcon from '@assets/img/dashboard/ordinalBRC20.svg';
+import stacksIcon from '@assets/img/dashboard/stx_icon.svg';
import ArrowSwap from '@assets/img/icons/ArrowSwap.svg';
import AccountHeaderComponent from '@components/accountHeader';
import BottomModal from '@components/bottomModal';
@@ -20,12 +20,15 @@ import useCoinRates from '@hooks/queries/useCoinRates';
import useFeeMultipliers from '@hooks/queries/useFeeMultipliers';
import useStxWalletData from '@hooks/queries/useStxWalletData';
import useHasFeature from '@hooks/useHasFeature';
+import useNotificationBanners from '@hooks/useNotificationBanners';
+import useTrackMixPanelPageViewed from '@hooks/useTrackMixPanelPageViewed';
import useWalletSelector from '@hooks/useWalletSelector';
import { ArrowDown, ArrowUp, Plus } from '@phosphor-icons/react';
import CoinSelectModal from '@screens/home/coinSelectModal';
-import type { FungibleToken } from '@secretkeylabs/xverse-core';
+import { type FungibleToken } from '@secretkeylabs/xverse-core';
import { changeShowDataCollectionAlertAction } from '@stores/wallet/actions/actionCreators';
import Button from '@ui-library/button';
+import Divider from '@ui-library/divider';
import Sheet from '@ui-library/sheet';
import { CurrencyTypes } from '@utils/constants';
import { isInOptions, isLedgerAccount } from '@utils/helper';
@@ -38,7 +41,9 @@ import { useNavigate } from 'react-router-dom';
import styled, { useTheme } from 'styled-components';
import SquareButton from '../../components/squareButton';
import BalanceCard from './balanceCard';
+import Banner from './banner';
+// TODO: Move this styles to ./index.styled.ts
export const Container = styled.div`
display: flex;
flex-direction: column;
@@ -114,11 +119,6 @@ const Icon = styled.img({
height: 24,
});
-const MergedIcon = styled.img({
- width: 40,
- height: 24,
-});
-
const MergedOrdinalsIcon = styled.img({
width: 64,
height: 24,
@@ -171,18 +171,45 @@ const ModalButtonContainer = styled.div((props) => ({
},
}));
+const StacksIcon = styled.img({
+ width: 24,
+ height: 24,
+ position: 'absolute',
+ zIndex: 2,
+ left: 0,
+ top: 0,
+});
+
+const MergedIcon = styled.div((props) => ({
+ position: 'relative',
+ marginBottom: props.theme.spacing(12),
+}));
+
+const IconBackground = styled.div((props) => ({
+ width: 24,
+ height: 24,
+ position: 'absolute',
+ zIndex: 1,
+ left: 20,
+ top: 0,
+ backgroundColor: props.theme.colors.white_900,
+ borderRadius: 30,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+}));
+
+const StyledDivider = styled(Divider)`
+ flex: 1 0 auto;
+ width: calc(100% + ${(props) => props.theme.space.xl});
+ margin-left: -${(props) => props.theme.space.m};
+ margin-right: -${(props) => props.theme.space.m};
+`;
+
function Home() {
const { t } = useTranslation('translation', {
keyPrefix: 'DASHBOARD_SCREEN',
});
- const theme = useTheme();
- const navigate = useNavigate();
- const dispatch = useDispatch();
- const [openReceiveModal, setOpenReceiveModal] = useState(false);
- const [openSendModal, setOpenSendModal] = useState(false);
- const [openBuyModal, setOpenBuyModal] = useState(false);
- const [isBtcReceiveAlertVisible, setIsBtcReceiveAlertVisible] = useState(false);
- const [isOrdinalReceiveAlertVisible, setIsOrdinalReceiveAlertVisible] = useState(false);
const {
stxAddress,
btcAddress,
@@ -193,16 +220,25 @@ function Home() {
showDataCollectionAlert,
network,
hideStx,
+ notificationBanners,
} = useWalletSelector();
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const [openReceiveModal, setOpenReceiveModal] = useState(false);
+ const [openSendModal, setOpenSendModal] = useState(false);
+ const [openBuyModal, setOpenBuyModal] = useState(false);
+ const [isBtcReceiveAlertVisible, setIsBtcReceiveAlertVisible] = useState(false);
+ const [isOrdinalReceiveAlertVisible, setIsOrdinalReceiveAlertVisible] = useState(false);
const [areReceivingAddressesVisible, setAreReceivingAddressesVisible] = useState(
!isLedgerAccount(selectedAccount),
);
const [choseToVerifyAddresses, setChoseToVerifyAddresses] = useState(false);
- const { isLoading: loadingStxWalletData, isRefetching: refetchingStxWalletData } =
- // eslint-disable-next-line react-hooks/rules-of-hooks
- stxAddress ? useStxWalletData() : { isLoading: false, isRefetching: false };
+ const { isInitialLoading: loadingStxWalletData, isRefetching: refetchingStxWalletData } =
+ useStxWalletData();
const { isLoading: loadingBtcWalletData, isRefetching: refetchingBtcWalletData } =
useBtcWalletData();
+ const { data: notificationBannersArr } = useNotificationBanners();
const {
visible: sip10CoinsList,
isLoading: loadingStxCoinData,
@@ -218,9 +254,16 @@ function Home() {
isLoading: loadingRunesData,
isRefetching: refetchingRunesData,
} = useVisibleRuneFungibleTokens();
+
useFeeMultipliers();
useCoinRates();
useAppConfig();
+ useTrackMixPanelPageViewed();
+
+ const showNotificationBanner =
+ notificationBannersArr?.length &&
+ notificationBannersArr.length > 0 &&
+ !notificationBanners[notificationBannersArr[0].id];
const onReceiveModalOpen = () => {
setOpenReceiveModal(true);
@@ -252,7 +295,8 @@ function Home() {
};
const sendSheetCoinsList = (stxAddress ? sip10CoinsList : [])
- .concat(brc20CoinsList)
+ // ENG-4020 - Disable BRC20 Sending on Ledger
+ .concat(isLedgerAccount(selectedAccount) ? [] : brc20CoinsList)
.concat(runesCoinsList)
.filter((ft) => new BigNumber(ft.balance).gt(0));
@@ -405,7 +449,12 @@ function Home() {
showVerifyButton={choseToVerifyAddresses}
currency="STX"
>
-
+
+
+
+
+
+
)}
@@ -485,7 +534,7 @@ function Home() {
refetchingRunesData
}
/>
-
+
}
text={t('SEND')}
@@ -504,6 +553,15 @@ function Home() {
/>
+ {showNotificationBanner && (
+ <>
+
+
+
+
+ >
+ )}
+
{btcAddress && (
-
({
@@ -94,14 +98,18 @@ const ArrowContainer = styled.div`
z-index: 1;
`;
-const StyledCaretLeft = styled(CaretLeft)<{ disabled: boolean }>((props) => ({
- opacity: props.disabled ? '60%' : '100%',
- cursor: props.disabled ? 'default' : 'pointer',
-}));
-
-const StyledCaretRight = styled(CaretRight)<{ disabled: boolean }>((props) => ({
- opacity: props.disabled ? '60%' : '100%',
+const CaretButton = styled.button<{ disabled: boolean }>((props) => ({
+ backgroundColor: 'transparent',
cursor: props.disabled ? 'default' : 'pointer',
+ svg: {
+ opacity: props.disabled ? 0.6 : 1,
+ transition: 'opacity 0.1s ease',
+ },
+ '&:hover, &:focus': {
+ svg: {
+ opacity: 0.8,
+ },
+ },
}));
const AnimationContainer = styled.div({
@@ -183,48 +191,12 @@ const BottomContainer = styled.div`
animation: ${() => slideYAndOpacity} 0.2s ease-out;
`;
-const CreateButton = styled.button((props) => ({
- display: 'flex',
- ...props.theme.typography.body_medium_m,
- color: props.theme.colors.elevation0,
- textAlign: 'center',
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- borderRadius: props.theme.radius(1),
- backgroundColor: props.theme.colors.action.classic,
+const CreateButton = styled(Button)((props) => ({
marginTop: props.theme.space.l,
- width: '100%',
- height: 44,
- ':hover': {
- background: props.theme.colors.action.classicLight,
- },
- ':focus': {
- background: props.theme.colors.action.classicLight,
- opacity: 0.6,
- },
}));
-const RestoreButton = styled.button((props) => ({
- display: 'flex',
+const RestoreButton = styled(Button)((props) => ({
marginTop: props.theme.space.s,
- ...props.theme.typography.body_medium_m,
- color: props.theme.colors.white_0,
- textAlign: 'center',
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- borderRadius: props.theme.radius(1),
- backgroundColor: props.theme.colors.elevation0,
- border: `0.5px solid ${props.theme.colors.elevation2}`,
- width: '100%',
- height: 44,
- ':hover': {
- background: props.theme.colors.elevation6_800,
- },
- ':focus': {
- background: props.theme.colors.action.classic800,
- },
}));
function Landing() {
@@ -233,6 +205,7 @@ function Landing() {
const [animationComplete, setAnimationComplete] = useState(false);
const [slideTransitions, setSlideTransitions] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<'left' | 'right'>('right');
+ const navigate = useNavigate();
const theme = useTheme();
const onboardingViews = [
@@ -250,25 +223,43 @@ function Landing() {
},
];
- const proceedToWallet = useCallback(async (isRestore?: boolean) => {
- const isLegalAccepted = getIsTermsAccepted();
- if (isLegalAccepted) {
- if (isRestore) {
- await chrome.tabs.create({
- url: chrome.runtime.getURL(`options.html#/restoreWallet`),
- });
+ const proceedToWallet = useCallback(
+ async (isRestore?: boolean) => {
+ const isLegalAccepted = getIsTermsAccepted();
+
+ if (isInOptions()) {
+ if (isLegalAccepted) {
+ if (isRestore) {
+ navigate(`/restoreWallet`);
+ } else {
+ navigate(`/backup`);
+ }
+ } else {
+ const params = isRestore ? '?restore=true' : '';
+ navigate(`/legal${params}`);
+ }
+ return;
+ }
+
+ if (isLegalAccepted) {
+ if (isRestore) {
+ await chrome.tabs.create({
+ url: chrome.runtime.getURL(`options.html#/restoreWallet`),
+ });
+ } else {
+ await chrome.tabs.create({
+ url: chrome.runtime.getURL(`options.html#/backup`),
+ });
+ }
} else {
+ const params = isRestore ? '?restore=true' : '';
await chrome.tabs.create({
- url: chrome.runtime.getURL(`options.html#/backup`),
+ url: chrome.runtime.getURL(`options.html#/legal${params}`),
});
}
- } else {
- const params = isRestore ? '?restore=true' : '';
- await chrome.tabs.create({
- url: chrome.runtime.getURL(`options.html#/legal${params}`),
- });
- }
- }, []);
+ },
+ [navigate],
+ );
const renderLandingSection = () => (
<>
@@ -337,34 +328,37 @@ function Landing() {
return (
- Beta
+ {t('BETA')}
{animationComplete ? (
- 0 ? '100%' : '60%'}
- />
-
+ 0 ? '100%' : '60%'}
+ />
+
+ = onboardingViews.length - 1}
onClick={handleClickNext}
- size={theme.space.l}
- color={theme.colors.white_0}
- opacity={currentStepIndex < onboardingViews.length - 1 ? '100%' : '60%'}
- />
+ >
+
+
{renderTransitions()}
- proceedToWallet()}>
- {t('CREATE_WALLET_BUTTON')}
-
- proceedToWallet(true)}>
- {t('RESTORE_WALLET_BUTTON')}
-
+ proceedToWallet()} title={t('CREATE_WALLET_BUTTON')} />
+ proceedToWallet(true)}
+ title={t('RESTORE_WALLET_BUTTON')}
+ />
) : (
diff --git a/src/app/screens/ledger/confirmLedgerTransaction/index.styled.ts b/src/app/screens/ledger/confirmLedgerTransaction/index.styled.ts
index e275def9f..c2be25a13 100644
--- a/src/app/screens/ledger/confirmLedgerTransaction/index.styled.ts
+++ b/src/app/screens/ledger/confirmLedgerTransaction/index.styled.ts
@@ -55,8 +55,9 @@ export const InfoImage = styled.img({
height: 64,
});
-export const ConnectLedgerTitle = styled.h1((props) => ({
+export const ConnectLedgerTitle = styled.h1<{ textAlign?: 'left' | 'center' }>((props) => ({
...props.theme.headline_s,
+ textAlign: props.textAlign || 'left',
marginBottom: props.theme.spacing(6),
}));
diff --git a/src/app/screens/ledger/confirmLedgerTransaction/index.tsx b/src/app/screens/ledger/confirmLedgerTransaction/index.tsx
index 378cb3223..77258b318 100644
--- a/src/app/screens/ledger/confirmLedgerTransaction/index.tsx
+++ b/src/app/screens/ledger/confirmLedgerTransaction/index.tsx
@@ -21,15 +21,15 @@ import useWalletSelector from '@hooks/useWalletSelector';
import Transport from '@ledgerhq/hw-transport-webusb';
import { useTransition } from '@react-spring/web';
import {
+ Recipient,
+ StacksRecipient,
+ UTXO,
broadcastSignedTransaction,
microstacksToStx,
- Recipient,
satsToBtc,
signLedgerMixedBtcTransaction,
signLedgerNativeSegwitBtcTransaction,
signLedgerStxTransaction,
- StacksRecipient,
- UTXO,
} from '@secretkeylabs/xverse-core';
import { DEFAULT_TRANSITION_OPTIONS } from '@utils/constants';
import { getBtcTxStatusUrl, getStxTxStatusUrl, getTruncatedAddress } from '@utils/helper';
@@ -99,6 +99,7 @@ function ConfirmLedgerTransaction(): JSX.Element {
ordinalUtxo?: UTXO;
feeRateInput?: string;
fee?: BigNumber;
+ messageId?: string;
} = location.state;
const transition = useTransition(currentStep, DEFAULT_TRANSITION_OPTIONS);
diff --git a/src/app/screens/legal/index.tsx b/src/app/screens/legal/index.tsx
index b30b5053d..4dc70ba33 100644
--- a/src/app/screens/legal/index.tsx
+++ b/src/app/screens/legal/index.tsx
@@ -1,11 +1,10 @@
import LinkIcon from '@assets/img/linkIcon.svg';
-import ActionButton from '@components/button';
import Separator from '@components/separator';
import useWalletSelector from '@hooks/useWalletSelector';
import { CustomSwitch } from '@screens/ledger/importLedgerAccount/steps/index.styled';
import { changeShowDataCollectionAlertAction } from '@stores/wallet/actions/actionCreators';
+import Button from '@ui-library/button';
import { PRIVACY_POLICY_LINK, TERMS_LINK } from '@utils/constants';
-import { isInOptions } from '@utils/helper';
import { saveIsTermsAccepted } from '@utils/localStorage';
import { optInMixPanel, optOutMixPanel } from '@utils/mixpanel';
import { useState } from 'react';
@@ -49,7 +48,8 @@ const Link = styled.a((props) => ({
const CustomizedLink = styled(Link)`
transition: opacity 0.1s ease;
- :hover {
+ :hover,
+ &:focus {
opacity: 0.8;
}
:active {
@@ -129,7 +129,7 @@ function Legal() {
-
+
);
}
diff --git a/src/app/screens/manageTokens/coinItem/index.tsx b/src/app/screens/manageTokens/coinItem/index.tsx
index 82bf69da9..5286f66b1 100644
--- a/src/app/screens/manageTokens/coinItem/index.tsx
+++ b/src/app/screens/manageTokens/coinItem/index.tsx
@@ -59,10 +59,12 @@ function CoinItem({ id, name, image, ticker, protocol, disabled, toggled, enable
};
return (
-
+
- {name}
+
+ {name}
+
({
...ft,
- visible: sip10ManageTokens[ft.principal] ?? ft.visible,
+ visible: sip10ManageTokens[ft.principal] ?? new BigNumber(ft.balance).gt(0),
}));
error = sip10Error;
break;
case 'brc-20':
coins = (brc20List ?? []).map((ft) => ({
...ft,
- visible: brc20ManageTokens[ft.principal] ?? ft.visible,
+ visible: brc20ManageTokens[ft.principal] ?? new BigNumber(ft.balance).gt(0),
}));
error = brc20Error;
break;
case 'runes':
coins = (runesList ?? []).map((ft) => ({
...ft,
- visible: runesManageTokens[ft.principal] ?? ft.visible,
+ visible: runesManageTokens[ft.principal] ?? new BigNumber(ft.balance).gt(0),
}));
error = runeError;
break;
diff --git a/src/app/screens/nftDashboard/collectiblesTabs.tsx b/src/app/screens/nftDashboard/collectiblesTabs.tsx
index e470ba96f..213f9e9f7 100644
--- a/src/app/screens/nftDashboard/collectiblesTabs.tsx
+++ b/src/app/screens/nftDashboard/collectiblesTabs.tsx
@@ -1,6 +1,7 @@
import ActionButton from '@components/button';
import { StyledBarLoader, TilesSkeletonLoader } from '@components/tilesSkeletonLoader';
import WrenchErrorMessage from '@components/wrenchErrorMessage';
+import useTrackMixPanelPageViewed from '@hooks/useTrackMixPanelPageViewed';
import { Bundle, mapRareSatsAPIResponseToBundle } from '@secretkeylabs/xverse-core';
import { StyledP, StyledTab, StyledTabList } from '@ui-library/common.styled';
import { useEffect, useState } from 'react';
@@ -147,6 +148,13 @@ export default function CollectiblesTabs({
inscriptionsQuery,
} = nftDashboard;
+ useTrackMixPanelPageViewed(
+ {
+ tab: tabs[tabIndex]?.key,
+ },
+ [tabIndex],
+ );
+
const handleSelectTab = (index: number) => {
setTabIndex(index);
};
@@ -186,7 +194,9 @@ export default function CollectiblesTabs({
<>
{totalInscriptions > 0 && (
- {t('TOTAL_ITEMS', { count: totalInscriptions })}
+ {totalInscriptions === 1
+ ? t('TOTAL_ITEMS_ONE')
+ : t('TOTAL_ITEMS', { count: totalInscriptions })}
)}
{inscriptionListView}
@@ -201,7 +211,7 @@ export default function CollectiblesTabs({
<>
{totalNfts > 0 && (
- {t('TOTAL_ITEMS', { count: totalNfts })}
+ {totalNfts === 1 ? t('TOTAL_ITEMS_ONE') : t('TOTAL_ITEMS', { count: totalNfts })}
)}
{nftListView}
@@ -212,7 +222,9 @@ export default function CollectiblesTabs({
{!rareSatsQuery.isLoading && ordinalBundleCount > 0 && (
- {t('TOTAL_ITEMS', { count: ordinalBundleCount })}
+ {ordinalBundleCount === 1
+ ? t('TOTAL_ITEMS_ONE')
+ : t('TOTAL_ITEMS', { count: ordinalBundleCount })}
)}
diff --git a/src/app/screens/receive/index.tsx b/src/app/screens/receive/index.tsx
index 26472ef65..63ce4a415 100644
--- a/src/app/screens/receive/index.tsx
+++ b/src/app/screens/receive/index.tsx
@@ -1,6 +1,6 @@
+import StxIcon from '@assets/img/dashboard/stx_icon.svg';
import BtcIcon from '@assets/img/receive_btc_image.svg';
import OrdinalIcon from '@assets/img/receive_ordinals_image.svg';
-import StxIcon from '@assets/img/receive_stx_image.svg';
import ShowBtcReceiveAlert from '@components/showBtcReceiveAlert';
import ShowOrdinalReceiveAlert from '@components/showOrdinalReceiveAlert';
import BottomTabBar from '@components/tabBar';
@@ -81,7 +81,7 @@ const QRCodeContainer = styled.div<{ marginBottom: number }>((props) => ({
const AddressText = styled.h1((props) => ({
...props.theme.typography.body_m,
- textAlign: 'center',
+ textAlign: 'left',
color: props.theme.colors.white_200,
wordBreak: 'break-all',
}));
@@ -126,7 +126,7 @@ function Receive() {
title: t('STX_ADDRESS'),
desc: t('STX_RECEIVE_MESSAGE'),
icon: StxIcon,
- gradient: '#7B61FF',
+ gradient: '#FC6432',
},
ORD: {
address: ordinalsAddress,
diff --git a/src/app/screens/receive/qrCode.tsx b/src/app/screens/receive/qrCode.tsx
index d645b63c2..c3c57f174 100644
--- a/src/app/screens/receive/qrCode.tsx
+++ b/src/app/screens/receive/qrCode.tsx
@@ -43,7 +43,7 @@ function QrCode({ image, data, gradientColor }: Props) {
type: 'dot',
},
imageOptions: {
- hideBackgroundDots: true,
+ hideBackgroundDots: false,
imageSize: 1,
},
qrOptions: {
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) => (
+
+
+
+ ))}
+
+
+
+
+
+ >
+ ) : (
+ setFeeRate(newFeeRate.toString())}
+ feeRate={+feeRate}
+ isError={!!error}
+ hideBottomBar={false}
+ selectedBottomTab="settings"
+ showAccountHeader={false}
+ isBroadcast
+ onBackClick={handleToggleConfirmTx}
+ />
+ );
+ }
+ return (
+ <>
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {error}
+
+ )}
+
+ {!isLoading && (
+
+
+
+ )}
+
+ >
+ );
+}
+
+export default RecoverRunes;
diff --git a/src/app/screens/restoreFunds/restoreOrdinals/index.tsx b/src/app/screens/restoreFunds/restoreOrdinals/index.tsx
index 3642d6e75..762488b6e 100644
--- a/src/app/screens/restoreFunds/restoreOrdinals/index.tsx
+++ b/src/app/screens/restoreFunds/restoreOrdinals/index.tsx
@@ -22,7 +22,7 @@ import styled from 'styled-components';
import OrdinalRow from './ordinalRow';
const RestoreFundTitle = styled.h1((props) => ({
- ...props.theme.body_l,
+ ...props.theme.typography.body_l,
marginBottom: 32,
color: props.theme.colors.white_200,
}));
@@ -170,7 +170,7 @@ function RestoreOrdinals() {
>
)}
-
+
>
);
}
diff --git a/src/app/screens/restoreFunds/restoreOrdinals/ordinalRow.tsx b/src/app/screens/restoreFunds/restoreOrdinals/ordinalRow.tsx
index 372af796d..30a0a76f7 100644
--- a/src/app/screens/restoreFunds/restoreOrdinals/ordinalRow.tsx
+++ b/src/app/screens/restoreFunds/restoreOrdinals/ordinalRow.tsx
@@ -80,10 +80,7 @@ function OrdinalRow({ ordinal, isLoading, disableTransfer, handleOrdinalTransfer
Ordinal
- handleOrdinalTransfer(ordinal)}
- disabled={disableTransfer}
- >
+ handleOrdinalTransfer(ordinal)} disabled={disableTransfer}>
{isLoading ? (
diff --git a/src/app/screens/restoreWallet/enterSeedphrase.tsx b/src/app/screens/restoreWallet/enterSeedphrase.tsx
index fde95449b..b6b683ace 100644
--- a/src/app/screens/restoreWallet/enterSeedphrase.tsx
+++ b/src/app/screens/restoreWallet/enterSeedphrase.tsx
@@ -1,9 +1,9 @@
-import ActionButton from '@components/button';
import SeedPhraseInput from '@components/seedPhraseInput';
+import Button from '@ui-library/button';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
-const Container = styled.div({
+const Form = styled.form({
display: 'flex',
flexDirection: 'column',
flex: 1,
@@ -38,13 +38,19 @@ function EnterSeedPhrase(props: Props): JSX.Element {
const { t } = useTranslation('translation', { keyPrefix: 'RESTORE_WALLET_SCREEN' });
return (
-
+
+
);
}
diff --git a/src/app/screens/restoreWallet/index.tsx b/src/app/screens/restoreWallet/index.tsx
index 588670df6..394d4926a 100644
--- a/src/app/screens/restoreWallet/index.tsx
+++ b/src/app/screens/restoreWallet/index.tsx
@@ -116,6 +116,7 @@ function RestoreWallet(): JSX.Element {
handleBack={handleNewPasswordBack}
checkPasswordStrength
createPasswordFlow
+ autoFocus
/>
,
@@ -128,9 +129,11 @@ function RestoreWallet(): JSX.Element {
handleBack={handleConfirmPasswordBack}
passwordError={error}
loading={isRestoring}
+ autoFocus
/>
,
];
+
return (
diff --git a/src/app/screens/sendBtc/stepDisplay.tsx b/src/app/screens/sendBtc/stepDisplay.tsx
index 0f344e71d..6caae368d 100644
--- a/src/app/screens/sendBtc/stepDisplay.tsx
+++ b/src/app/screens/sendBtc/stepDisplay.tsx
@@ -125,6 +125,7 @@ function StepDisplay({
inputs={summary.inputs}
outputs={summary.outputs}
feeOutput={summary.feeOutput}
+ showCenotaphCallout={!!summary?.runeOp?.Cenotaph?.flaws}
isLoading={false}
confirmText={t('COMMON.CONFIRM')}
cancelText={t('COMMON.CANCEL')}
diff --git a/src/app/screens/sendRune/amountSelector.tsx b/src/app/screens/sendRune/amountSelector.tsx
index 977b59002..09028d3ca 100644
--- a/src/app/screens/sendRune/amountSelector.tsx
+++ b/src/app/screens/sendRune/amountSelector.tsx
@@ -71,12 +71,10 @@ function AmountSelector({
const satsToFiat = (sats: string) =>
getBtcFiatEquivalent(new BigNumber(sats), BigNumber(btcFiatRate)).toNumber().toFixed(2);
- const isSendButtonEnabled =
- amountToSend !== '' &&
- !Number.isNaN(Number(amountToSend)) &&
- !Number.isNaN(Number(balance)) &&
- +amountToSend > 0 &&
- +amountToSend <= +balance;
+ const amountIsPositiveNumber =
+ amountToSend !== '' && !Number.isNaN(Number(amountToSend)) && +amountToSend > 0;
+
+ const isSendButtonEnabled = amountIsPositiveNumber && +amountToSend <= +balance;
return (
@@ -112,11 +110,13 @@ function AmountSelector({
diff --git a/src/app/screens/sendRune/index.tsx b/src/app/screens/sendRune/index.tsx
index df8b0c4ab..f003b0dbf 100644
--- a/src/app/screens/sendRune/index.tsx
+++ b/src/app/screens/sendRune/index.tsx
@@ -1,10 +1,17 @@
import { useGetRuneFungibleTokens } from '@hooks/queries/runes/useGetRuneFungibleTokens';
+import useBtcClient from '@hooks/useBtcClient';
import useBtcFeeRate from '@hooks/useBtcFeeRate';
import useHasFeature from '@hooks/useHasFeature';
import { useResetUserFlow } from '@hooks/useResetUserFlow';
import useTransactionContext from '@hooks/useTransactionContext';
import useWalletSelector from '@hooks/useWalletSelector';
-import { btcTransaction, FungibleToken, Transport } from '@secretkeylabs/xverse-core';
+import {
+ FungibleToken,
+ RuneSummary,
+ Transport,
+ btcTransaction,
+ parseSummaryForRunes,
+} from '@secretkeylabs/xverse-core';
import { isInOptions, isLedgerAccount } from '@utils/helper';
import { getFtBalance } from '@utils/tokens';
import BigNumber from 'bignumber.js';
@@ -13,7 +20,7 @@ import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { generateTransaction, type TransactionSummary } from './helpers';
import StepDisplay from './stepDisplay';
-import { getPreviousStep, Step } from './steps';
+import { Step, getPreviousStep } from './steps';
function SendRuneScreen() {
const navigate = useNavigate();
@@ -24,9 +31,9 @@ function SendRuneScreen() {
const location = useLocation();
const { t } = useTranslation('translation');
const { data: btcFeeRate, isLoading: feeRatesLoading } = useBtcFeeRate();
- const { selectedAccount } = useWalletSelector();
+ const { selectedAccount, network } = useWalletSelector();
const { data: runesCoinsList } = useGetRuneFungibleTokens();
-
+ const context = useTransactionContext();
const [recipientAddress, setRecipientAddress] = useState(location.state?.recipientAddress || '');
const [amountError, setAmountError] = useState('');
const [amountToSend, setAmountToSend] = useState(location.state?.amount || '');
@@ -38,10 +45,12 @@ function SendRuneScreen() {
const transactionContext = useTransactionContext();
const [transaction, setTransaction] = useState();
const [summary, setSummary] = useState();
+ const [runeSummary, setRuneSummary] = useState();
const coinTicker = location.search ? location.search.split('coinTicker=')[1] : undefined;
const fungibleToken: FungibleToken =
location.state?.fungibleToken || runesCoinsList?.find((coin) => coin.ticker === coinTicker);
+ const hasRunesSupport = useHasFeature('RUNES_SUPPORT');
useEffect(() => {
if (!feeRate && btcFeeRate && !feeRatesLoading) {
@@ -72,11 +81,15 @@ function SendRuneScreen() {
};
useEffect(() => {
- if (!recipientAddress || !feeRate) {
+ const bigAmount = BigNumber(amountToSend);
+
+ if (!recipientAddress || !feeRate || bigAmount.isNaN() || bigAmount.isLessThanOrEqualTo(0)) {
setTransaction(undefined);
setSummary(undefined);
+ setRuneSummary(undefined);
return;
}
+
// This effect can be slow to compute as it signs transactions, but
// it can also be very fast if there is not enough rune balance
// this can lead to a race condition where entering an amount to send
@@ -89,11 +102,14 @@ function SendRuneScreen() {
setIsLoading(true);
try {
const transactionDetails = await generateTransactionAndSummary();
-
if (!isActiveEffect) return;
-
setTransaction(transactionDetails.transaction);
- setSummary(transactionDetails.summary);
+ if (transactionDetails.summary) {
+ setSummary(transactionDetails.summary);
+ setRuneSummary(
+ await parseSummaryForRunes(context, transactionDetails.summary, network.type),
+ );
+ }
} catch (e) {
if (!(e instanceof Error) || !e.message.includes('Insufficient funds')) {
// don't log the error if it's just an insufficient funds error
@@ -102,7 +118,9 @@ function SendRuneScreen() {
setTransaction(undefined);
setSummary(undefined);
} finally {
- setIsLoading(false);
+ if (isActiveEffect) {
+ setIsLoading(false);
+ }
}
};
generateTxnAndSummary();
@@ -111,13 +129,6 @@ function SendRuneScreen() {
};
}, [transactionContext, recipientAddress, amountToSend, feeRate, sendMax]);
- const hasRunesSupport = useHasFeature('RUNES_SUPPORT');
-
- if (!hasRunesSupport) {
- navigate('/');
- return null;
- }
-
const handleCancel = () => {
if (isLedgerAccount(selectedAccount) && isInOption) {
window.close();
@@ -181,10 +192,16 @@ function SendRuneScreen() {
}
};
+ if (!hasRunesSupport) {
+ navigate('/');
+ return null;
+ }
+
return (
void;
amountError: string;
@@ -53,6 +54,7 @@ type StepDisplayProps = {
function StepDisplay({
token,
summary,
+ runeSummary,
amountToSend,
setAmountToSend,
amountError,
@@ -126,12 +128,11 @@ function StepDisplay({
}
return (
{t('RESET_TO_DEFAULT')}
-
+
{value && (
diff --git a/src/app/screens/settings/fiatCurrency/index.tsx b/src/app/screens/settings/fiatCurrency/index.tsx
index 04d5b822b..67433c8a6 100644
--- a/src/app/screens/settings/fiatCurrency/index.tsx
+++ b/src/app/screens/settings/fiatCurrency/index.tsx
@@ -1,5 +1,8 @@
import BottomBar from '@components/tabBar';
import TopRow from '@components/topRow';
+import { useGetBrc20FungibleTokens } from '@hooks/queries/ordinals/useGetBrc20FungibleTokens';
+import { useGetSip10FungibleTokens } from '@hooks/queries/stx/useGetSip10FungibleTokens';
+import useCoinRates from '@hooks/queries/useCoinRates';
import useWalletSelector from '@hooks/useWalletSelector';
import { SupportedCurrency } from '@secretkeylabs/xverse-core';
import { ChangeFiatCurrencyAction } from '@stores/wallet/actions/actionCreators';
@@ -28,6 +31,11 @@ function FiatCurrencyScreen() {
const navigate = useNavigate();
const dispatch = useDispatch();
+ // here to fetch new rates on currency change, to avoid glitches in home balance card and tokens fiat value
+ useCoinRates();
+ useGetSip10FungibleTokens();
+ useGetBrc20FungibleTokens();
+
const handleBackButtonClick = () => {
navigate('/settings');
};
diff --git a/src/app/screens/settings/index.tsx b/src/app/screens/settings/index.tsx
index 91f64a1e7..8576c24eb 100644
--- a/src/app/screens/settings/index.tsx
+++ b/src/app/screens/settings/index.tsx
@@ -248,13 +248,6 @@ function Setting() {
toggleValue={hasActivatedOrdinalsKey}
showDivider
/>
-
-
+
({
width: '100%',
}));
+const WarningCallout = styled(Callout)`
+ margin-bottom: ${(props) => props.theme.space.m};
+`;
+
const ReviewTransactionText = styled.h1((props) => ({
...props.theme.headline_s,
color: props.theme.colors.white_0,
@@ -116,12 +124,8 @@ interface TxResponse {
psbtBase64: string;
}
-type PsbtSummary = {
- inputs: btcTransaction.EnhancedInput[];
- outputs: btcTransaction.EnhancedOutput[];
- feeOutput?: btcTransaction.TransactionFeeOutput | undefined;
- hasSigHashNone: boolean;
-};
+// TODO: export this from core
+type PsbtSummary = Awaited>;
function SignBatchPsbtRequest() {
const { btcAddress, ordinalsAddress, selectedAccount, network } = useWalletSelector();
@@ -141,14 +145,21 @@ function SignBatchPsbtRequest() {
const [inscriptionToShow, setInscriptionToShow] = useState<
btcTransaction.IOInscription | undefined
>(undefined);
+ const hasRunesSupport = useHasFeature('RUNES_SUPPORT');
- const [parsedPsbts, setParsedPsbts] = useState([]);
+ const [parsedPsbts, setParsedPsbts] = useState<
+ { summary: PsbtSummary; runeSummary: RuneSummary | undefined }[]
+ >([]);
const handlePsbtParsing = useCallback(
- (psbt: SignMultiplePsbtPayload, index: number) => {
+ async (psbt: SignMultiplePsbtPayload, index: number) => {
try {
const parsedPsbt = new btcTransaction.EnhancedPsbt(txnContext, psbt.psbtBase64);
- return parsedPsbt.getSummary();
+ const summary = await parsedPsbt.getSummary();
+ const runeSummary = hasRunesSupport
+ ? await parseSummaryForRunes(txnContext, summary, network.type)
+ : undefined;
+ return { summary, runeSummary };
} catch (err) {
navigate('/tx-status', {
state: {
@@ -168,12 +179,12 @@ function SignBatchPsbtRequest() {
useEffect(() => {
(async () => {
const parsedPsbtsResult = await Promise.all(payload.psbts.map(handlePsbtParsing));
-
if (parsedPsbtsResult.some((item) => item === undefined)) {
return setIsLoading(false);
}
-
- setParsedPsbts(parsedPsbtsResult as PsbtSummary[]);
+ setParsedPsbts(
+ parsedPsbtsResult as { summary: PsbtSummary; runeSummary: RuneSummary | undefined }[],
+ );
setIsLoading(false);
})();
}, [payload.psbts.length, handlePsbtParsing]);
@@ -241,7 +252,7 @@ function SignBatchPsbtRequest() {
const signingMessage = {
source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.signBatchPsbtResponse,
+ method: SatsConnectMethods.signBatchPsbtResponse,
payload: {
signBatchPsbtRequest: requestToken,
signBatchPsbtResponse: signedPsbts,
@@ -278,12 +289,12 @@ function SignBatchPsbtRequest() {
const totalNetAmount = parsedPsbts.reduce(
(sum, psbt) =>
- psbt
+ psbt && psbt.summary
? sum.plus(
new BigNumber(
getNetAmount({
- inputs: psbt.inputs,
- outputs: psbt.outputs,
+ inputs: psbt.summary.inputs,
+ outputs: psbt.summary.outputs,
btcAddress,
ordinalsAddress,
}),
@@ -293,12 +304,12 @@ function SignBatchPsbtRequest() {
new BigNumber(0),
);
const totalFeeAmount = parsedPsbts.reduce((sum, psbt) => {
- const feeAmount = psbt.feeOutput?.amount ?? 0;
+ const feeAmount = psbt.summary.feeOutput?.amount ?? 0;
return sum.plus(new BigNumber(feeAmount));
}, new BigNumber(0));
const hasOutputScript = useMemo(
- () => parsedPsbts.some((psbt) => psbt.outputs.some((output) => isScriptOutput(output))),
+ () => parsedPsbts.some((psbt) => psbt.summary.outputs.some((output) => isScriptOutput(output))),
[parsedPsbts.length],
);
@@ -323,6 +334,10 @@ function SignBatchPsbtRequest() {
);
}
+ const isPartialTransaction = parsedPsbts.some((psbt) => !psbt.summary.feeOutput);
+ const runeBurns = parsedPsbts.map((psbt) => psbt.runeSummary?.burns ?? []).flat();
+ const hasSomeRuneDelegation = (runeBurns.length ?? 0) > 0 && isPartialTransaction;
+
return (
<>
@@ -342,7 +357,6 @@ function SignBatchPsbtRequest() {
{t('SIGN_TRANSACTIONS', { count: parsedPsbts.length })}
-
setReviewTransaction(true)}>
{t('REVIEW_ALL')}
@@ -357,20 +371,29 @@ function SignBatchPsbtRequest() {
}}
/>
)}
+ {hasSomeRuneDelegation && }
psbt.inputs).flat()}
- outputs={parsedPsbts.map((psbt) => psbt.outputs).flat()}
+ inputs={parsedPsbts.map((psbt) => psbt.summary.inputs).flat()}
+ outputs={parsedPsbts.map((psbt) => psbt.summary.outputs).flat()}
+ runeTransfers={parsedPsbts
+ .map((psbt) => psbt.runeSummary?.transfers ?? [])
+ .flat()}
netAmount={(totalNetAmount.toNumber() + totalFeeAmount.toNumber()) * -1}
- isPartialTransaction={parsedPsbts.some((psbt) => !psbt.feeOutput)}
+ isPartialTransaction={isPartialTransaction}
onShowInscription={setInscriptionToShow}
/>
psbt.outputs).flat()}
+ outputs={parsedPsbts.map((psbt) => psbt.summary.outputs).flat()}
+ runeReceipts={parsedPsbts.map((psbt) => psbt.runeSummary?.receipts ?? []).flat()}
onShowInscription={setInscriptionToShow}
netAmount={totalNetAmount.toNumber()}
/>
+ {!isPartialTransaction && }
+ psbt.runeSummary?.mint)} />
- {hasOutputScript && }
+ {hasOutputScript && !parsedPsbts.some((psbt) => psbt.runeSummary !== undefined) && (
+
+ )}
)}
@@ -400,13 +423,16 @@ function SignBatchPsbtRequest() {
{t('TRANSACTION')} {currentPsbtIndex + 1}/{parsedPsbts.length}
-
{!!parsedPsbts[currentPsbtIndex] && (
)}
diff --git a/src/app/screens/signMessageRequest/index.tsx b/src/app/screens/signMessageRequest/index.tsx
new file mode 100644
index 000000000..6c37e3e36
--- /dev/null
+++ b/src/app/screens/signMessageRequest/index.tsx
@@ -0,0 +1,369 @@
+import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg';
+import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg';
+import { MESSAGE_SOURCE, SatsConnectMethods } from '@common/types/message-types';
+import { delay } from '@common/utils/ledger';
+import { makeRPCError, makeRpcSuccessResponse, sendRpcResponse } from '@common/utils/rpc/helpers';
+import AccountHeaderComponent from '@components/accountHeader';
+import BottomModal from '@components/bottomModal';
+import ConfirmScreen from '@components/confirmScreen';
+import InfoContainer from '@components/infoContainer';
+import LedgerConnectionView from '@components/ledger/connectLedgerView';
+import RequestError from '@components/requests/requestError';
+import useWalletSelector from '@hooks/useWalletSelector';
+import Transport from '@ledgerhq/hw-transport-webusb';
+import CollapsableContainer from '@screens/signatureRequest/collapsableContainer';
+import SignatureRequestMessage from '@screens/signatureRequest/signatureRequestMessage';
+import { finalizeMessageSignature } from '@screens/signatureRequest/utils';
+import { bip0322Hash } from '@secretkeylabs/xverse-core';
+import Button from '@ui-library/button';
+import { getTruncatedAddress, isHardwareAccount } from '@utils/helper';
+import { handleBip322LedgerMessageSigning } from '@utils/ledger';
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Return, RpcErrorCode } from 'sats-connect';
+import styled from 'styled-components';
+import { useSignMessageRequest, useSignMessageValidation } from './useSignMessageRequest';
+
+const MainContainer = styled.div((props) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ paddingLeft: props.theme.spacing(8),
+ paddingRight: props.theme.spacing(8),
+}));
+
+const RequestType = styled.h1((props) => ({
+ ...props.theme.typography.headline_s,
+ marginTop: props.theme.spacing(11),
+ color: props.theme.colors.white_0,
+ textAlign: 'left',
+ marginBottom: props.theme.spacing(12),
+}));
+
+const MessageHash = styled.p((props) => ({
+ ...props.theme.typography.body_medium_m,
+ textAlign: 'left',
+ lineHeight: 1.6,
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_0,
+ marginBottom: props.theme.spacing(4),
+}));
+
+const SigningAddressContainer = styled.div((props) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ background: props.theme.colors.elevation1,
+ borderRadius: 12,
+ padding: '12px 16px',
+ marginBottom: props.theme.spacing(6),
+ flex: 1,
+}));
+
+const SigningAddressTitle = styled.p((props) => ({
+ ...props.theme.typography.body_medium_m,
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_200,
+ marginBottom: props.theme.spacing(4),
+}));
+
+const SigningAddress = styled.div({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+});
+
+const SigningAddressType = styled.p((props) => ({
+ ...props.theme.typography.body_medium_m,
+ textAlign: 'left',
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_0,
+ marginBottom: props.theme.spacing(4),
+}));
+
+const SigningAddressValue = styled.p((props) => ({
+ ...props.theme.typography.body_medium_m,
+ textAlign: 'left',
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_0,
+ marginBottom: props.theme.spacing(4),
+}));
+
+const ActionDisclaimer = styled.p((props) => ({
+ ...props.theme.typography.body_m,
+ color: props.theme.colors.white_400,
+ marginTop: props.theme.spacing(4),
+ marginBottom: props.theme.spacing(8),
+}));
+
+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),
+}));
+
+function SignMessageRequest() {
+ const { t } = useTranslation('translation');
+ const { accountsList, selectedAccount, network } = useWalletSelector();
+ const { payload, tabId, requestToken, confirmSignMessage, requestId } = useSignMessageRequest();
+ const { validationError } = useSignMessageValidation(payload);
+
+ const [addressType, setAddressType] = useState('');
+ const [isSigning, setIsSigning] = useState(false);
+
+ // Ledger state
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [isConnectSuccess, setIsConnectSuccess] = useState(false);
+ const [isConnectFailed, setIsConnectFailed] = useState(false);
+ const [isButtonDisabled, setIsButtonDisabled] = useState(false);
+ const [currentStepIndex, setCurrentStepIndex] = useState(0);
+ const [isTxApproved, setIsTxApproved] = useState(false);
+ const [isTxRejected, setIsTxRejected] = useState(false);
+ const [isTxInvalid, setIsTxInvalid] = useState(false);
+
+ useEffect(() => {
+ const checkAddressAvailability = () => {
+ const account = accountsList.filter((acc) => {
+ if (acc.btcAddress === payload.address) {
+ setAddressType(t('SIGNATURE_REQUEST.SIGNING_ADDRESS_SEGWIT'));
+ return true;
+ }
+ if (acc.ordinalsAddress === payload?.address) {
+ setAddressType(t('SIGNATURE_REQUEST.SIGNING_ADDRESS_TAPROOT'));
+ return true;
+ }
+ return false;
+ });
+ return isHardwareAccount(selectedAccount) ? account[0] || selectedAccount : account[0];
+ };
+ checkAddressAvailability();
+ }, [payload]);
+
+ const getConfirmationError = (type: 'title' | 'subtitle') => {
+ if (type === 'title') {
+ if (isTxRejected) {
+ return t('SIGNATURE_REQUEST.LEDGER.CONFIRM.DENIED.ERROR_TITLE');
+ }
+
+ if (isTxInvalid) {
+ return t('SIGNATURE_REQUEST.LEDGER.CONFIRM.INVALID.ERROR_TITLE');
+ }
+
+ return t('SIGNATURE_REQUEST.LEDGER.CONFIRM.ERROR_TITLE');
+ }
+
+ if (isTxRejected) {
+ return t('SIGNATURE_REQUEST.LEDGER.CONFIRM.DENIED.ERROR_SUBTITLE');
+ }
+
+ if (isTxInvalid) {
+ return t('SIGNATURE_REQUEST.LEDGER.CONFIRM.INVALID.ERROR_SUBTITLE');
+ }
+
+ return t('SIGNATURE_REQUEST.LEDGER.CONFIRM.ERROR_SUBTITLE');
+ };
+
+ const handleConnectAndConfirm = async () => {
+ if (!selectedAccount) {
+ console.error('No account selected');
+ return;
+ }
+ setIsButtonDisabled(true);
+
+ const transport = await Transport.create();
+
+ if (!transport) {
+ setIsConnectSuccess(false);
+ setIsConnectFailed(true);
+ setIsButtonDisabled(false);
+ return;
+ }
+
+ setIsConnectSuccess(true);
+ await delay(1500);
+ setCurrentStepIndex(1);
+
+ try {
+ const signature = await handleBip322LedgerMessageSigning({
+ transport,
+ addressIndex: selectedAccount.deviceAccountIndex,
+ address: payload.address,
+ networkType: network.type,
+ message: payload.message,
+ });
+ const signingMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.signMessageResponse,
+ payload: {
+ signMessageRequest: requestToken,
+ signMessageResponse: signature,
+ },
+ };
+ chrome.tabs.sendMessage(+tabId, signingMessage);
+ window.close();
+ } catch (e: any) {
+ console.error(e);
+
+ if (e.name === 'LockedDeviceError') {
+ setCurrentStepIndex(0);
+ setIsConnectSuccess(false);
+ setIsConnectFailed(true);
+ } else if (e.statusCode === 28160) {
+ setIsConnectSuccess(false);
+ setIsConnectFailed(true);
+ } else if (e.cause === 27012) {
+ setIsTxInvalid(true);
+ } else {
+ setIsTxRejected(true);
+ }
+ } finally {
+ await transport.close();
+ setIsButtonDisabled(false);
+ }
+ };
+
+ const cancelCallback = () => {
+ if (requestToken) {
+ finalizeMessageSignature({ requestPayload: requestToken, tabId: +tabId, data: 'cancel' });
+ } else {
+ const cancelError = makeRPCError(requestId, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the request.',
+ });
+ sendRpcResponse(+tabId, cancelError);
+ }
+ window.close();
+ };
+
+ const confirmCallback = async () => {
+ if (!payload) return;
+ try {
+ setIsSigning(true);
+ if (isHardwareAccount(selectedAccount)) {
+ setIsModalVisible(true);
+ return;
+ }
+ const bip322signature = await confirmSignMessage();
+ if (requestToken) {
+ const signingMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.signMessageResponse,
+ payload: {
+ signMessageRequest: requestToken,
+ signMessageResponse: bip322signature,
+ },
+ };
+ chrome.tabs.sendMessage(+tabId, signingMessage);
+ } else {
+ const signMessageResult: Return<'signMessage'> = {
+ address: payload.address,
+ messageHash: bip0322Hash(payload.message),
+ signature: bip322signature ?? '',
+ };
+ const response = makeRpcSuccessResponse(requestId, signMessageResult);
+ sendRpcResponse(+tabId, response);
+ }
+ window.close();
+ } catch (err) {
+ console.log(err);
+ } finally {
+ setIsSigning(false);
+ }
+ };
+
+ const handleRetry = async () => {
+ setIsTxRejected(false);
+ setIsTxInvalid(false);
+ setIsConnectFailed(false);
+ setIsConnectSuccess(false);
+ setCurrentStepIndex(0);
+ };
+
+ return !validationError ? (
+ <>
+
+
+
+ {t('SIGNATURE_REQUEST.TITLE')}
+
+
+ {bip0322Hash(payload.message)}
+
+
+
+ {t('SIGNATURE_REQUEST.SIGNING_ADDRESS_TITLE')}
+
+
+ {addressType && {addressType}}
+ {getTruncatedAddress(payload.address, 6)}
+
+
+ {t('SIGNATURE_REQUEST.ACTION_DISCLAIMER')}
+
+
+
+ setIsModalVisible(false)}>
+ {currentStepIndex === 0 && (
+
+ )}
+ {currentStepIndex === 1 && (
+
+ )}
+
+
+
+
+
+ >
+ ) : (
+
+ );
+}
+
+export default SignMessageRequest;
diff --git a/src/app/screens/signMessageRequest/useSignMessageRequest.ts b/src/app/screens/signMessageRequest/useSignMessageRequest.ts
new file mode 100644
index 000000000..e5bb984d6
--- /dev/null
+++ b/src/app/screens/signMessageRequest/useSignMessageRequest.ts
@@ -0,0 +1,145 @@
+import useSeedVault from '@hooks/useSeedVault';
+import useWalletReducer from '@hooks/useWalletReducer';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { SettingsNetwork, signBip322Message } from '@secretkeylabs/xverse-core';
+import { isHardwareAccount } from '@utils/helper';
+import { decodeToken } from 'jsontokens';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useLocation } from 'react-router-dom';
+import { BitcoinNetworkType, SignMessageOptions, SignMessagePayload } from 'sats-connect';
+
+const useSignMessageRequestParams = (network: SettingsNetwork) => {
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+ const tabId = params.get('tabId') ?? '0';
+ const origin = params.get('origin') ?? '';
+ const requestId = params.get('requestId') ?? '';
+
+ const { payload, requestToken } = useMemo(() => {
+ const token = params.get('signMessageRequest') ?? '';
+ if (token) {
+ const request = decodeToken(token) as any as SignMessageOptions;
+ return {
+ payload: request.payload,
+ requestToken: token,
+ };
+ }
+ const address = params.get('address') ?? '';
+ const message = params.get('message') ?? '';
+ const rpcPayload: SignMessagePayload = {
+ message,
+ address,
+ network:
+ network.type === 'Mainnet'
+ ? {
+ type: BitcoinNetworkType.Mainnet,
+ }
+ : {
+ type: BitcoinNetworkType.Testnet,
+ },
+ };
+ return {
+ payload: rpcPayload,
+ requestToken: null,
+ };
+ }, []);
+
+ return { tabId, origin, payload, requestToken, requestId };
+};
+
+type ValidationError = {
+ error: string;
+ errorTitle?: string;
+};
+
+export const useSignMessageValidation = (requestPayload: SignMessagePayload | undefined) => {
+ const [validationError, setValidationError] = useState(null);
+ const { t } = useTranslation('translation', { keyPrefix: 'REQUEST_ERRORS' });
+ const { accountsList, selectedAccount, network } = useWalletSelector();
+ const { switchAccount } = useWalletReducer();
+
+ const checkAddressAvailability = () => {
+ const account = accountsList.filter((acc) => {
+ if (acc.btcAddress === requestPayload?.address) {
+ return true;
+ }
+ if (acc.ordinalsAddress === requestPayload?.address) {
+ return true;
+ }
+ return false;
+ });
+ return isHardwareAccount(selectedAccount) ? account[0] || selectedAccount : account[0];
+ };
+
+ const validateSignMessage = () => {
+ if (!requestPayload) return;
+ if (requestPayload.network.type !== network.type) {
+ setValidationError({
+ error: t('NETWORK_MISMATCH'),
+ });
+ return;
+ }
+ const account = checkAddressAvailability();
+ if (account) {
+ switchAccount(account);
+ } else {
+ setValidationError({
+ error: t('ADDRESS_MISMATCH'),
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (requestPayload) {
+ validateSignMessage();
+ }
+ return () => {
+ setValidationError(null);
+ };
+ }, [requestPayload]);
+
+ return { validationError, validateSignMessage };
+};
+
+export const useSignMessageRequest = () => {
+ const { network, accountsList } = useWalletSelector();
+ const { getSeed } = useSeedVault();
+ const { payload, requestToken, tabId, origin, requestId } = useSignMessageRequestParams(network);
+
+ const confirmSignMessage = async () => {
+ const { address, message } = payload;
+ const seedPhrase = await getSeed();
+ return signBip322Message({
+ accounts: accountsList,
+ message,
+ signatureAddress: address,
+ seedPhrase,
+ network: network.type,
+ });
+ };
+
+ return {
+ payload,
+ requestToken,
+ tabId,
+ origin,
+ requestId,
+ confirmSignMessage,
+ };
+};
+
+export function useSignBip322Message(message: string, address: string) {
+ const { accountsList, network } = useWalletSelector();
+ const { getSeed } = useSeedVault();
+ return useCallback(async () => {
+ const seedPhrase = await getSeed();
+ return signBip322Message({
+ accounts: accountsList,
+ message,
+ signatureAddress: address,
+ seedPhrase,
+ network: network.type,
+ });
+ }, []);
+}
diff --git a/src/app/screens/signPsbtRequest/index.tsx b/src/app/screens/signPsbtRequest/index.tsx
index fccafc694..c890a917d 100644
--- a/src/app/screens/signPsbtRequest/index.tsx
+++ b/src/app/screens/signPsbtRequest/index.tsx
@@ -1,58 +1,69 @@
+import { makeRPCError, sendRpcResponse } from '@common/utils/rpc/helpers';
import ConfirmBitcoinTransaction from '@components/confirmBtcTransaction';
-import useSignPsbtTx from '@hooks/useSignPsbtTx';
+import RequestError from '@components/requests/requestError';
+import useHasFeature from '@hooks/useHasFeature';
import useTransactionContext from '@hooks/useTransactionContext';
import useWalletSelector from '@hooks/useWalletSelector';
-import { btcTransaction, Transport } from '@secretkeylabs/xverse-core';
-import { useEffect, useMemo, useState } from 'react';
+import {
+ RuneSummary,
+ Transport,
+ btcTransaction,
+ parseSummaryForRunes,
+} from '@secretkeylabs/xverse-core';
+import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
+import { RpcErrorCode } from 'sats-connect';
+import useSignPsbt from './useSignPsbt';
import useSignPsbtValidationGate from './useSignPsbtValidationGate';
+// TODO: export this from core
+type PSBTSummary = Awaited>;
+
function SignPsbtRequest() {
const navigate = useNavigate();
+
const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
+ const txnContext = useTransactionContext();
const [isLoading, setIsLoading] = useState(true);
const [isSigning, setIsSigning] = useState(false);
- const [inputs, setInputs] = useState([]);
- const [outputs, setOutputs] = useState([]);
- const [feeOutput, setFeeOutput] = useState();
+ const [summary, setSummary] = useState();
+ const [runeSummary, setRuneSummary] = useState(undefined);
+ const hasRunesSupport = useHasFeature('RUNES_SUPPORT');
- const { payload, confirmSignPsbt, cancelSignPsbt } = useSignPsbtTx();
- const txnContext = useTransactionContext();
- const parsedPsbt = useMemo(() => {
- try {
- return new btcTransaction.EnhancedPsbt(txnContext, payload.psbtBase64, payload.inputsToSign);
- } catch (err) {
- return undefined;
- }
- }, [txnContext, payload.psbtBase64]);
+ const { payload, parsedPsbt, confirmSignPsbt, cancelSignPsbt, onCloseError, requestId, tabId } =
+ useSignPsbt();
+ const { validationError, setValidationError } = useSignPsbtValidationGate({
+ payload,
+ parsedPsbt,
+ });
useSignPsbtValidationGate({ payload, parsedPsbt });
- const { btcAddress, ordinalsAddress } = useWalletSelector();
+ const { network } = useWalletSelector();
+
useEffect(() => {
if (!parsedPsbt) return;
parsedPsbt
.getSummary()
- .then((summary) => {
- const { feeOutput: psbtFeeOutput, inputs: psbtInputs, outputs: psbtOutputs } = summary;
- setFeeOutput(psbtFeeOutput);
- setInputs(psbtInputs);
- setOutputs(psbtOutputs);
+ .then(async (txSummary) => {
+ setSummary(txSummary);
+ if (hasRunesSupport) {
+ setRuneSummary(await parseSummaryForRunes(txnContext, txSummary, network.type));
+ }
setIsLoading(false);
})
- .catch((error) => {
- console.error(error);
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'BTC',
- errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'),
- error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'),
- browserTx: true,
- },
+ .catch((err) => {
+ const error = makeRPCError(requestId, {
+ code: RpcErrorCode.INTERNAL_ERROR,
+ message: err,
+ });
+ sendRpcResponse(+tabId, error);
+ setValidationError({
+ error: JSON.stringify(err),
+ errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -74,7 +85,7 @@ function SignPsbtRequest() {
if (payload.broadcast) {
navigate('/tx-status', {
state: {
- txid: response.txId,
+ txid: response?.txId,
currency: 'BTC',
error: '',
browserTx: true,
@@ -104,14 +115,28 @@ function SignPsbtRequest() {
window.close();
};
- return (
+ const onCloseClick = () => {
+ onCloseError(validationError?.error || '');
+ window.close();
+ };
+
+ return validationError ? (
+
+ ) : (
{
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+ const tabId = params.get('tabId') ?? '0';
+ const requestId = params.get('requestId') ?? '';
+
+ const { requestToken, payload } = useMemo(() => {
+ const token = params.get('signPsbtRequest') ?? '';
+ if (token) {
+ const request = decodeToken(token) as any as SignTransactionOptions;
+ return {
+ payload: request.payload,
+ requestToken: token,
+ };
+ }
+ const allowedSigHash = params.get('allowedSigHash') ?? '';
+ const signInputs = JSON.parse(params.get('signInputs')!) as Record;
+ const inputsToSign: InputToSign[] = Object.keys(signInputs).map((address) => ({
+ address,
+ signingIndexes: signInputs[address],
+ sigHash: +allowedSigHash,
+ }));
+ const rpcPayload: SignTransactionPayload = {
+ psbtBase64: params.get('psbt') ?? '',
+ inputsToSign,
+ broadcast: Boolean(params.get('broadcast')) ?? false,
+ message: params.get('message') ?? '',
+ network:
+ network.type === 'Mainnet'
+ ? {
+ type: BitcoinNetworkType.Mainnet,
+ }
+ : {
+ type: BitcoinNetworkType.Testnet,
+ },
+ };
+ return {
+ payload: rpcPayload,
+ requestToken: null,
+ };
+ }, []);
+
+ return { payload, tabId, requestToken, requestId };
+};
+
+const useSignPsbt = () => {
+ const { accountsList, network } = useWalletSelector();
+ const { getSeed } = useSeedVault();
+ const btcClient = useBtcClient();
+ const { payload, tabId, requestToken, requestId } = useSignPsbtParams(network);
+
+ const txnContext = useTransactionContext();
+
+ const parsedPsbt = useMemo(() => {
+ try {
+ if (!payload) return;
+ return new btcTransaction.EnhancedPsbt(txnContext, payload.psbtBase64, payload.inputsToSign);
+ } catch (err) {
+ return undefined;
+ }
+ }, [txnContext, payload]);
+
+ const confirmSignPsbt = async (signingResponseOverride?: string) => {
+ let signingResponse = signingResponseOverride;
+ if (!signingResponse && payload) {
+ const seedPhrase = await getSeed();
+ signingResponse = await signPsbt(
+ seedPhrase,
+ accountsList,
+ payload.inputsToSign,
+ payload.psbtBase64,
+ payload.broadcast,
+ network.type,
+ );
+ }
+
+ let txId: string = '';
+ if (payload.broadcast && signingResponse) {
+ const txHex = psbtBase64ToHex(signingResponse);
+ const response = await btcClient.sendRawTransaction(txHex);
+ txId = response.tx.hash;
+ }
+ if (!signingResponse) return;
+ if (requestToken) {
+ const signingMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.signPsbtResponse,
+ payload: {
+ signPsbtRequest: requestToken,
+ signPsbtResponse: {
+ psbtBase64: signingResponse,
+ txId,
+ },
+ },
+ };
+ chrome.tabs.sendMessage(+tabId, signingMessage);
+ } else {
+ const result: Return<'signPsbt'> = {
+ psbt: signingResponse,
+ txid: txId,
+ };
+ const response = makeRpcSuccessResponse(requestId as string, result);
+ sendRpcResponse(+tabId, response);
+ }
+ return {
+ txId,
+ signingResponse,
+ };
+ };
+
+ /**
+ * User cancels the sign psbt request
+ */
+ const cancelSignPsbt = () => {
+ if (requestToken) {
+ const signingMessage = {
+ source: MESSAGE_SOURCE,
+ method: SatsConnectMethods.signPsbtResponse,
+ payload: { signPsbtRequest: requestToken, signPsbtResponse: 'cancel' },
+ };
+ chrome.tabs.sendMessage(+tabId, signingMessage);
+ } else {
+ const cancelError = makeRPCError(requestId as string, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to sign Psbt',
+ });
+ sendRpcResponse(+tabId, cancelError);
+ }
+ };
+
+ /**
+ * User closes request validation error
+ */
+ const onCloseError = (error: string) => {
+ const requestError = makeRPCError(requestId, {
+ code: RpcErrorCode.INTERNAL_ERROR,
+ message: error,
+ });
+ sendRpcResponse(+tabId, requestError);
+ };
+
+ const getSigningAddresses = (inputsToSign: InputToSign[]) => {
+ const signingAddresses: Array = [];
+ inputsToSign.forEach((inputToSign) => {
+ inputToSign.signingIndexes.forEach((signingIndex) => {
+ signingAddresses[signingIndex] = inputToSign.address;
+ });
+ });
+ return signingAddresses;
+ };
+
+ return {
+ payload,
+ parsedPsbt,
+ tabId,
+ requestId,
+ requestToken,
+ getSigningAddresses,
+ confirmSignPsbt,
+ cancelSignPsbt,
+ onCloseError,
+ };
+};
+
+export default useSignPsbt;
diff --git a/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts b/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts
index cd81c775a..a0dac2140 100644
--- a/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts
+++ b/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts
@@ -1,56 +1,55 @@
import useWalletSelector from '@hooks/useWalletSelector';
import { btcTransaction } from '@secretkeylabs/xverse-core';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
import { SignTransactionPayload } from 'sats-connect';
type Props = {
- payload: SignTransactionPayload;
+ payload: SignTransactionPayload | undefined;
parsedPsbt: btcTransaction.EnhancedPsbt | undefined;
};
+
+type ValidationError = {
+ error: string;
+ errorTitle?: string;
+};
+
const useSignPsbtValidationGate = ({ payload, parsedPsbt }: Props) => {
const { btcAddress, ordinalsAddress, network } = useWalletSelector();
- const navigate = useNavigate();
- const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' });
+ const { t } = useTranslation('translation', { keyPrefix: 'REQUEST_ERRORS' });
+ const [validationError, setValidationError] = useState(null);
useEffect(() => {
+ if (!payload) return;
if (!parsedPsbt) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'BTC',
- errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'),
- error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'),
- browserTx: true,
- },
+ setValidationError({
+ error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'),
+ errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'),
});
+ return;
}
if (payload.network.type !== network.type) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'BTC',
- error: t('NETWORK_MISMATCH'),
- browserTx: true,
- },
+ setValidationError({
+ error: t('NETWORK_MISMATCH'),
});
+ return;
}
if (payload.inputsToSign) {
payload.inputsToSign.forEach((input) => {
if (input.address !== btcAddress && input.address !== ordinalsAddress) {
- navigate('/tx-status', {
- state: {
- txid: '',
- currency: 'BTC',
- error: t('ADDRESS_MISMATCH'),
- browserTx: true,
- },
+ setValidationError({
+ error: t('ADDRESS_MISMATCH'),
});
}
});
+ return;
}
- });
+ return () => {
+ setValidationError(null);
+ };
+ }, [payload, parsedPsbt]);
+
+ return { validationError, setValidationError };
};
export default useSignPsbtValidationGate;
diff --git a/src/app/screens/signatureRequest/index.styled.ts b/src/app/screens/signatureRequest/index.styled.ts
new file mode 100644
index 000000000..c8350c485
--- /dev/null
+++ b/src/app/screens/signatureRequest/index.styled.ts
@@ -0,0 +1,100 @@
+import styled from 'styled-components';
+
+export const OuterContainer = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+});
+
+export const InnerContainer = styled.div((props) => ({
+ flex: 1,
+ ...props.theme.scrollbar,
+}));
+
+export const MainContainer = styled.div((props) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ paddingLeft: props.theme.spacing(4),
+ paddingRight: props.theme.spacing(4),
+}));
+
+export const RequestType = styled.h1((props) => ({
+ ...props.theme.headline_s,
+ marginTop: props.theme.spacing(11),
+ color: props.theme.colors.white_0,
+ textAlign: 'left',
+ marginBottom: props.theme.spacing(4),
+}));
+
+export const RequestSource = styled.h2((props) => ({
+ ...props.theme.body_medium_m,
+ color: props.theme.colors.white_400,
+ textAlign: 'left',
+ marginBottom: props.theme.spacing(12),
+}));
+
+export const MessageHash = styled.p((props) => ({
+ ...props.theme.body_medium_m,
+ textAlign: 'left',
+ lineHeight: 1.6,
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_0,
+ marginBottom: props.theme.spacing(4),
+}));
+
+export const SigningAddressContainer = styled.div((props) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ background: props.theme.colors.elevation1,
+ borderRadius: 12,
+ padding: '12px 16px',
+ marginBottom: props.theme.spacing(6),
+ flex: 1,
+}));
+
+export const SigningAddressTitle = styled.p((props) => ({
+ ...props.theme.body_medium_m,
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_200,
+ marginBottom: props.theme.spacing(4),
+}));
+
+export const SigningAddress = styled.div({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+});
+
+export const SigningAddressType = styled.p((props) => ({
+ ...props.theme.body_medium_m,
+ textAlign: 'left',
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_0,
+ marginBottom: props.theme.spacing(4),
+}));
+
+export const SigningAddressValue = styled.p((props) => ({
+ ...props.theme.body_medium_m,
+ textAlign: 'left',
+ wordWrap: 'break-word',
+ color: props.theme.colors.white_0,
+ marginBottom: props.theme.spacing(4),
+}));
+
+export const ActionDisclaimer = styled.p((props) => ({
+ ...props.theme.body_m,
+ color: props.theme.colors.white_400,
+ marginTop: props.theme.spacing(4),
+ marginBottom: props.theme.spacing(8),
+}));
+
+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/signatureRequest/index.tsx b/src/app/screens/signatureRequest/index.tsx
index 455d04e95..4774a02de 100644
--- a/src/app/screens/signatureRequest/index.tsx
+++ b/src/app/screens/signatureRequest/index.tsx
@@ -1,8 +1,7 @@
import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg';
-import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg';
import ledgerConnectStxIcon from '@assets/img/ledger/ledger_import_connect_stx.svg';
-import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types';
import { delay } from '@common/utils/ledger';
+import { makeRpcSuccessResponse, sendRpcResponse } from '@common/utils/rpc/helpers';
import AccountHeaderComponent from '@components/accountHeader';
import BottomModal from '@components/bottomModal';
import ActionButton from '@components/button';
@@ -12,124 +11,39 @@ import LedgerConnectionView from '@components/ledger/connectLedgerView';
import useSignatureRequest, {
isStructuredMessage,
isUtf8Message,
- useSignBip322Message,
useSignMessage,
} from '@hooks/useSignatureRequest';
import useWalletReducer from '@hooks/useWalletReducer';
import useWalletSelector from '@hooks/useWalletSelector';
import Transport from '@ledgerhq/hw-transport-webusb';
-import { bip0322Hash, buf2hex, hashMessage, signStxMessage } from '@secretkeylabs/xverse-core';
+import { buf2hex, hashMessage, signStxMessage } from '@secretkeylabs/xverse-core';
import { SignaturePayload, StructuredDataSignaturePayload } from '@stacks/connect';
import { getNetworkType, getTruncatedAddress, isHardwareAccount } from '@utils/helper';
-import { handleBip322LedgerMessageSigning, signatureVrsToRsv } from '@utils/ledger';
+import { signatureVrsToRsv } from '@utils/ledger';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
-import styled from 'styled-components';
+import { Return } from 'sats-connect';
import CollapsableContainer from './collapsableContainer';
+import {
+ ActionDisclaimer,
+ InnerContainer,
+ MainContainer,
+ MessageHash,
+ OuterContainer,
+ RequestSource,
+ RequestType,
+ SigningAddress,
+ SigningAddressContainer,
+ SigningAddressTitle,
+ SigningAddressType,
+ SigningAddressValue,
+ SuccessActionsContainer,
+} from './index.styled';
import SignatureRequestMessage from './signatureRequestMessage';
import SignatureRequestStructuredData from './signatureRequestStructuredData';
import { finalizeMessageSignature } from './utils';
-const OuterContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- height: '100%',
-});
-
-const InnerContainer = styled.div((props) => ({
- flex: 1,
- ...props.theme.scrollbar,
-}));
-
-export const MainContainer = styled.div((props) => ({
- display: 'flex',
- flexDirection: 'column',
- paddingLeft: props.theme.spacing(4),
- paddingRight: props.theme.spacing(4),
-}));
-
-const RequestType = styled.h1((props) => ({
- ...props.theme.headline_s,
- marginTop: props.theme.spacing(11),
- color: props.theme.colors.white_0,
- textAlign: 'left',
- marginBottom: props.theme.spacing(4),
-}));
-
-const RequestSource = styled.h2((props) => ({
- ...props.theme.body_medium_m,
- color: props.theme.colors.white_400,
- textAlign: 'left',
- marginBottom: props.theme.spacing(12),
-}));
-
-const MessageHash = styled.p((props) => ({
- ...props.theme.body_medium_m,
- textAlign: 'left',
- lineHeight: 1.6,
- wordWrap: 'break-word',
- color: props.theme.colors.white_0,
- marginBottom: props.theme.spacing(4),
-}));
-
-const SigningAddressContainer = styled.div((props) => ({
- display: 'flex',
- flexDirection: 'column',
- background: props.theme.colors.elevation1,
- borderRadius: 12,
- padding: '12px 16px',
- marginBottom: props.theme.spacing(6),
- flex: 1,
-}));
-
-const SigningAddressTitle = styled.p((props) => ({
- ...props.theme.body_medium_m,
- wordWrap: 'break-word',
- color: props.theme.colors.white_200,
- marginBottom: props.theme.spacing(4),
-}));
-
-const SigningAddress = styled.div({
- display: 'flex',
- justifyContent: 'space-between',
- alignItems: 'center',
-});
-
-const SigningAddressType = styled.p((props) => ({
- ...props.theme.body_medium_m,
- textAlign: 'left',
- wordWrap: 'break-word',
- color: props.theme.colors.white_0,
- marginBottom: props.theme.spacing(4),
-}));
-
-const SigningAddressValue = styled.p((props) => ({
- ...props.theme.body_medium_m,
- textAlign: 'left',
- wordWrap: 'break-word',
- color: props.theme.colors.white_0,
- marginBottom: props.theme.spacing(4),
-}));
-
-const ActionDisclaimer = styled.p((props) => ({
- ...props.theme.body_m,
- color: props.theme.colors.white_400,
- marginTop: props.theme.spacing(4),
- marginBottom: props.theme.spacing(8),
-}));
-
-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),
-}));
-
function SignatureRequest(): JSX.Element {
const { t } = useTranslation('translation');
const [isSigning, setIsSigning] = useState(false);
@@ -144,22 +58,13 @@ function SignatureRequest(): JSX.Element {
const { selectedAccount, accountsList, network } = useWalletSelector();
const [addressType, setAddressType] = useState('');
const { switchAccount } = useWalletReducer();
- const { messageType, request, payload, tabId, domain, isSignMessageBip322 } =
- useSignatureRequest();
+ const { messageType, requestToken, payload, tabId, domain, requestId } = useSignatureRequest();
const navigate = useNavigate();
const isMessageSigningDisabled =
- isHardwareAccount(selectedAccount) && !isSignMessageBip322 && !selectedAccount?.stxAddress;
+ isHardwareAccount(selectedAccount) && !selectedAccount?.stxAddress;
const checkAddressAvailability = () => {
const account = accountsList.filter((acc) => {
- if (acc.btcAddress === payload.address) {
- setAddressType(t('SIGNATURE_REQUEST.SIGNING_ADDRESS_SEGWIT'));
- return true;
- }
- if (acc.ordinalsAddress === payload.address) {
- setAddressType(t('SIGNATURE_REQUEST.SIGNING_ADDRESS_TAPROOT'));
- return true;
- }
if (acc.stxAddress === payload.stxAddress) {
setAddressType(t('SIGNATURE_REQUEST.SIGNING_ADDRESS_STX'));
return true;
@@ -170,7 +75,7 @@ function SignatureRequest(): JSX.Element {
};
const switchAccountBasedOnRequest = () => {
- if (!isSignMessageBip322 && getNetworkType(payload.network) !== network.type) {
+ if (getNetworkType(payload.network) !== network.type) {
navigate('/tx-status', {
state: {
txid: '',
@@ -204,10 +109,10 @@ function SignatureRequest(): JSX.Element {
const handleMessageSigning = useSignMessage(messageType);
- const handleBip322MessageSigning = useSignBip322Message(payload.message, payload.address);
-
const cancelCallback = () => {
- finalizeMessageSignature({ requestPayload: request, tabId: +tabId, data: 'cancel' });
+ if (requestToken) {
+ finalizeMessageSignature({ requestPayload: requestToken, tabId: +tabId, data: 'cancel' });
+ }
window.close();
};
@@ -218,26 +123,26 @@ function SignatureRequest(): JSX.Element {
setIsModalVisible(true);
return;
}
- if (!isSignMessageBip322) {
- const signature = await handleMessageSigning({
- message: payload.message,
- domain: (domain as any) || undefined, // TODO fix type error
- });
- if (signature) {
- finalizeMessageSignature({ requestPayload: request, tabId: +tabId, data: signature });
+ const signature = await handleMessageSigning({
+ message: payload.message,
+ domain: (domain as any) || undefined, // TODO fix type error
+ });
+ if (signature) {
+ if (requestToken) {
+ finalizeMessageSignature({
+ requestPayload: requestToken,
+ tabId: +tabId,
+ data: signature,
+ });
+ } else {
+ const result: Return<'stx_signMessage' | 'stx_signStructuredMessage'> = {
+ signature: signature.signature,
+ publicKey: signature.publicKey,
+ };
+ const response = makeRpcSuccessResponse(requestId, result);
+ sendRpcResponse(+tabId, response);
+ window.close();
}
- } else {
- const bip322signature = await handleBip322MessageSigning();
- const signingMessage = {
- source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.signMessageResponse,
- payload: {
- signMessageRequest: request,
- signMessageResponse: bip322signature,
- },
- };
- chrome.tabs.sendMessage(+tabId, signingMessage);
- window.close();
}
} catch (err) {
console.log(err);
@@ -267,48 +172,37 @@ function SignatureRequest(): JSX.Element {
setCurrentStepIndex(1);
try {
- if (isSignMessageBip322) {
- const signature = await handleBip322LedgerMessageSigning({
- transport,
- addressIndex: selectedAccount.deviceAccountIndex,
- address: payload.address,
- networkType: network.type,
- message: payload.message,
- });
- const signingMessage = {
- source: MESSAGE_SOURCE,
- method: ExternalSatsMethods.signMessageResponse,
- payload: {
- signMessageRequest: request,
- signMessageResponse: signature,
- },
- };
- chrome.tabs.sendMessage(+tabId, signingMessage);
- window.close();
- } else {
- const signature = await signStxMessage({
- transport,
- message: payload.message,
- accountIndex: 0,
- addressIndex: selectedAccount.deviceAccountIndex,
+ const signature = await signStxMessage({
+ transport,
+ message: payload.message,
+ accountIndex: 0,
+ addressIndex: selectedAccount.deviceAccountIndex,
+ });
+ if (
+ !!signature.errorMessage &&
+ signature.errorMessage !== 'No errors' && // @zondax/ledger-stacks npm package returns this string when there are no errors
+ !!signature.returnCode
+ ) {
+ throw new Error(signature.errorMessage, {
+ cause: signature.returnCode,
});
- if (
- !!signature.errorMessage &&
- signature.errorMessage !== 'No errors' && // @zondax/ledger-stacks npm package returns this string when there are no errors
- !!signature.returnCode
- ) {
- throw new Error(signature.errorMessage, {
- cause: signature.returnCode,
- });
+ }
+ const rsvSignature = signatureVrsToRsv(signature.signatureVRS.toString('hex'));
+ const data = {
+ signature: rsvSignature,
+ publicKey: selectedAccount.stxPublicKey,
+ };
+ if (requestToken) {
+ if (signature) {
+ finalizeMessageSignature({ requestPayload: requestToken, tabId: +tabId, data });
}
- const rsvSignature = signatureVrsToRsv(signature.signatureVRS.toString('hex'));
- const data = {
+ } else {
+ const result: Return<'stx_signMessage' | 'stx_signStructuredMessage'> = {
signature: rsvSignature,
publicKey: selectedAccount.stxPublicKey,
};
- if (signature) {
- finalizeMessageSignature({ requestPayload: request, tabId: +tabId, data });
- }
+ const response = makeRpcSuccessResponse(requestId, result);
+ sendRpcResponse(+tabId, response);
}
} catch (e: any) {
console.error(e);
@@ -339,12 +233,10 @@ function SignatureRequest(): JSX.Element {
setCurrentStepIndex(0);
};
- const getMessageHash = useCallback(() => {
- if (!isSignMessageBip322) {
- return buf2hex(hashMessage(payload.message));
- }
- return bip0322Hash(payload.message);
- }, [isSignMessageBip322, payload.message]);
+ const getMessageHash = useCallback(
+ () => buf2hex(hashMessage(payload.message)),
+ [payload.message],
+ );
const getConfirmationError = (type: 'title' | 'subtitle') => {
if (type === 'title') {
@@ -400,15 +292,13 @@ function SignatureRequest(): JSX.Element {
) : (
{t('SIGNATURE_REQUEST.TITLE')}
- {!isSignMessageBip322 ? (
-
- {`${t('SIGNATURE_REQUEST.DAPP_NAME_PREFIX')} ${payload.appDetails?.name}`}
-
- ) : null}
- {(isUtf8Message(messageType) || isSignMessageBip322) && (
-
+
+ {`${t('SIGNATURE_REQUEST.DAPP_NAME_PREFIX')} ${payload.appDetails?.name}`}
+
+ {isUtf8Message(messageType) && (
+
)}
- {!isSignMessageBip322 && isStructuredMessage(messageType) && (
+ {isStructuredMessage(messageType) && (
@@ -426,7 +316,7 @@ function SignatureRequest(): JSX.Element {
{addressType && {addressType}}
- {getTruncatedAddress(payload.address || payload.stxAddress, 6)}
+ {getTruncatedAddress(payload.stxAddress, 6)}
@@ -439,11 +329,11 @@ function SignatureRequest(): JSX.Element {
diff --git a/src/app/screens/signatureRequest/signatureRequestMessage.tsx b/src/app/screens/signatureRequest/signatureRequestMessage.tsx
index 4a53b0d08..ed63d7279 100644
--- a/src/app/screens/signatureRequest/signatureRequestMessage.tsx
+++ b/src/app/screens/signatureRequest/signatureRequestMessage.tsx
@@ -1,14 +1,13 @@
-import { SignaturePayload } from '@stacks/connect';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import CollapsableContainer from './collapsableContainer';
interface SignatureRequestMessageProps {
- request: SignaturePayload;
+ message: string;
}
const RequestMessage = styled.p((props) => ({
- ...props.theme.body_medium_m,
+ ...props.theme.typography.body_medium_m,
textAlign: 'left',
wordWrap: 'break-word',
color: props.theme.colors.white_0,
@@ -16,11 +15,11 @@ const RequestMessage = styled.p((props) => ({
export default function SignatureRequestMessage(props: SignatureRequestMessageProps) {
const { t } = useTranslation('translation', { keyPrefix: 'SIGNATURE_REQUEST' });
- const { request } = props;
+ const { message } = props;
return (
-
- {request.message.split(/\r?\n/).map((line) => (
+
+ {message.split(/\r?\n/).map((line) => (
{line}
))}
diff --git a/src/app/screens/signatureRequest/utils.ts b/src/app/screens/signatureRequest/utils.ts
index 33b129e5e..d9c5bf0c2 100644
--- a/src/app/screens/signatureRequest/utils.ts
+++ b/src/app/screens/signatureRequest/utils.ts
@@ -1,9 +1,9 @@
-import { SignatureData } from '@stacks/connect';
import {
- ExternalMethods,
MESSAGE_SOURCE,
SignatureResponseMessage,
+ StacksLegacyMethods,
} from '@common/types/message-types';
+import { SignatureData } from '@stacks/connect';
interface FormatMessageSigningResponseArgs {
request: string;
@@ -15,7 +15,7 @@ export function formatMessageSigningResponse({
}: FormatMessageSigningResponseArgs): SignatureResponseMessage {
return {
source: MESSAGE_SOURCE,
- method: ExternalMethods.signatureResponse,
+ method: StacksLegacyMethods.signatureResponse,
payload: { signatureRequest: request, signatureResponse: response },
};
}
diff --git a/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx b/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx
index 24f910463..138d45ec3 100644
--- a/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx
+++ b/src/app/screens/stacking/stackingProgress/stackingStatusTile.tsx
@@ -1,4 +1,4 @@
-import TokenTicker from '@assets/img/stacking/token_ticker.svg';
+import TokenTicker from '@assets/img/dashboard/stx_icon.svg';
import useStackingData from '@hooks/queries/useStackingData';
import { StackingState } from '@secretkeylabs/xverse-core';
import { XVERSE_WEB_POOL_URL } from '@utils/constants';
@@ -66,6 +66,11 @@ const StatusText = styled.h1((props) => ({
color: props.theme.colors.white_0,
}));
+const Icon = styled.img({
+ width: 36,
+ height: 36,
+});
+
function StackingStatusTile() {
const { t } = useTranslation('translation', { keyPrefix: 'STACKING_SCREEN' });
const { stackingData } = useStackingData();
@@ -88,7 +93,7 @@ function StackingStatusTile() {
return (
-
+
{t('STACK_STX')}
{t('EARN_BTC')}
diff --git a/src/app/screens/swap/index.tsx b/src/app/screens/swap/index.tsx
index eb14465be..330c309b3 100644
--- a/src/app/screens/swap/index.tsx
+++ b/src/app/screens/swap/index.tsx
@@ -1,4 +1,3 @@
-import ActionButton from '@components/button';
import BottomBar from '@components/tabBar';
import TopRow from '@components/topRow';
import { ArrowDown } from '@phosphor-icons/react';
@@ -6,6 +5,7 @@ import CoinSelectModal from '@screens/home/coinSelectModal';
import { SwapInfoBlock } from '@screens/swap/swapInfoBlock';
import SwapTokenBlock from '@screens/swap/swapTokenBlock';
import { useSwap } from '@screens/swap/useSwap';
+import Button from '@ui-library/button';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -111,12 +111,12 @@ function SwapScreen() {
/>
)}
-
diff --git a/src/app/screens/swap/swapConfirmation/stxInfoBlock/index.tsx b/src/app/screens/swap/swapConfirmation/stxInfoBlock/index.tsx
index 83280c8bb..8120e1d05 100644
--- a/src/app/screens/swap/swapConfirmation/stxInfoBlock/index.tsx
+++ b/src/app/screens/swap/swapConfirmation/stxInfoBlock/index.tsx
@@ -148,7 +148,7 @@ export default function StxInfoBlock({ type, swap }: StxInfoCardProps) {
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+
{token.name}
diff --git a/src/app/screens/swap/swapConfirmation/useConfirmSwap.tsx b/src/app/screens/swap/swapConfirmation/useConfirmSwap.tsx
index 38a5684e8..fb0546304 100644
--- a/src/app/screens/swap/swapConfirmation/useConfirmSwap.tsx
+++ b/src/app/screens/swap/swapConfirmation/useConfirmSwap.tsx
@@ -94,7 +94,7 @@ export function useConfirmSwap(input: SwapConfirmationInput): SwapConfirmationOu
currency: 'STX',
error: '',
sponsored: isSponsored,
- browserTx: true,
+ browserTx: false,
},
});
}
@@ -107,7 +107,7 @@ export function useConfirmSwap(input: SwapConfirmationInput): SwapConfirmationOu
error:
e.code !== SponsoredTxErrorCode.unknown_error ? e.message : 'Unknown sponsor error',
sponsored: isSponsored,
- browserTx: true,
+ browserTx: false,
isSponsorServiceError: true,
isSwapTransaction: true,
},
@@ -119,7 +119,7 @@ export function useConfirmSwap(input: SwapConfirmationInput): SwapConfirmationOu
currency: 'STX',
error: e instanceof ApiResponseError ? (e.data as any).message : e.message,
sponsored: isSponsored,
- browserTx: true,
+ browserTx: false,
isSwapTransaction: true,
},
});
diff --git a/src/app/screens/swap/swapTokenBlock/index.tsx b/src/app/screens/swap/swapTokenBlock/index.tsx
index 019d7847d..920edbfa8 100644
--- a/src/app/screens/swap/swapTokenBlock/index.tsx
+++ b/src/app/screens/swap/swapTokenBlock/index.tsx
@@ -132,7 +132,7 @@ function SwapTokenBlock({
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
- {selectedCoin && }
+ {selectedCoin && }
{selectedCoin?.name ?? t('SELECT_COIN')}
diff --git a/src/app/screens/swap/types.ts b/src/app/screens/swap/types.ts
index 431ec592b..787fb998c 100644
--- a/src/app/screens/swap/types.ts
+++ b/src/app/screens/swap/types.ts
@@ -37,6 +37,7 @@ export type UseSwap = {
isServiceRunning: boolean;
handleChangeUserOverrideSponsorValue: (checked: boolean) => void;
isSponsorDisabled: boolean;
+ isLoadingRates: boolean;
};
export type SelectedCurrencyState = {
diff --git a/src/app/screens/swap/useStxCurrencyConversion.tsx b/src/app/screens/swap/useStxCurrencyConversion.tsx
index bdaa94477..830cb4bd3 100644
--- a/src/app/screens/swap/useStxCurrencyConversion.tsx
+++ b/src/app/screens/swap/useStxCurrencyConversion.tsx
@@ -1,4 +1,4 @@
-import { useVisibleSip10FungibleTokens } from '@hooks/queries/stx/useGetSip10FungibleTokens';
+import { useGetSip10FungibleTokens } from '@hooks/queries/stx/useGetSip10FungibleTokens';
import useWalletSelector from '@hooks/useWalletSelector';
import { FungibleToken, getFiatEquivalent, microstacksToStx } from '@secretkeylabs/xverse-core';
import { ftDecimals } from '@utils/helper';
@@ -10,20 +10,21 @@ import { SwapToken } from './types';
export function useStxCurrencyConversion() {
const alexSDK = new AlexSDK();
const { stxAvailableBalance, stxBtcRate, btcFiatRate } = useWalletSelector();
- const { visible: sip10CoinsList } = useVisibleSip10FungibleTokens();
+ const { data: sip10CoinsList } = useGetSip10FungibleTokens();
- const acceptableCoinList = sip10CoinsList
- .filter((sc) => alexSDK.getCurrencyFrom(sc.principal) != null)
- // TODO tim: remove this once alexsdk fix issue here
- // https://github.com/alexgo-io/alex-sdk/issues/2
- .filter((sc) => sc.principal !== 'SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.brc20-db20')
- .map((sc) => ({
- ...sc,
- assetName: '',
- total_sent: sc?.total_sent ?? '0',
- total_received: sc?.total_received ?? '0',
- balance: sc?.balance ?? '0',
- }));
+ const acceptableCoinList =
+ sip10CoinsList
+ ?.filter((sc) => alexSDK.getCurrencyFrom(sc.principal) != null)
+ // TODO tim: remove this once alexsdk fix issue here
+ // https://github.com/alexgo-io/alex-sdk/issues/2
+ .filter((sc) => sc.principal !== 'SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.brc20-db20')
+ .map((sc) => ({
+ ...sc,
+ assetName: '',
+ total_sent: sc?.total_sent ?? '0',
+ total_received: sc?.total_received ?? '0',
+ balance: sc?.balance ?? '0',
+ })) ?? [];
function currencyToToken(currency?: Currency, amount?: number): SwapToken | undefined {
if (currency == null) {
diff --git a/src/app/screens/swap/useSwap.tsx b/src/app/screens/swap/useSwap.tsx
index 5c0283104..57aefa7a9 100644
--- a/src/app/screens/swap/useSwap.tsx
+++ b/src/app/screens/swap/useSwap.tsx
@@ -90,6 +90,7 @@ export function useSwap(): UseSwap {
prevTo: undefined,
prevFrom: undefined,
});
+ const [isLoadingRates, setIsLoadingRates] = useState(false);
const fromAmount = Number.isNaN(Number(inputAmount)) ? undefined : Number(inputAmount);
@@ -164,7 +165,9 @@ export function useSwap(): UseSwap {
selectedCurrency.from === selectedCurrency.to
) {
setExchangeRate(undefined);
+ setIsLoadingRates(false);
} else {
+ setIsLoadingRates(true);
let cancelled = false;
alexSDK
.getAmountTo(
@@ -177,6 +180,9 @@ export function useSwap(): UseSwap {
return;
}
setExchangeRate(Number(result) / 1e8 / fromAmount);
+ })
+ .finally(() => {
+ setIsLoadingRates(false);
});
return () => {
cancelled = true;
@@ -294,5 +300,6 @@ export function useSwap(): UseSwap {
setUserOverrideSponsorValue(checked);
},
isSponsorDisabled,
+ isLoadingRates,
};
}
diff --git a/src/app/screens/transactionRequest/index.tsx b/src/app/screens/transactionRequest/index.tsx
index 5f3461f04..db925215c 100644
--- a/src/app/screens/transactionRequest/index.tsx
+++ b/src/app/screens/transactionRequest/index.tsx
@@ -1,7 +1,7 @@
+import { sendInternalErrorMessage } from '@common/utils/rpc/stx/rpcResponseMessages';
import ContractCallRequest from '@components/transactionsRequests/ContractCallRequest';
import ContractDeployRequest from '@components/transactionsRequests/ContractDeployTransaction';
import useNetworkSelector from '@hooks/useNetwork';
-import useStxTransactionRequest from '@hooks/useStxTransactionRequest';
import useWalletReducer from '@hooks/useWalletReducer';
import useWalletSelector from '@hooks/useWalletSelector';
import {
@@ -21,8 +21,10 @@ import Spinner from '@ui-library/spinner';
import { getNetworkType, isHardwareAccount } from '@utils/helper';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
+import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
+import useStxTransactionRequest from './useStxTransactionRequest';
const LoaderContainer = styled.div((props) => ({
display: 'flex',
@@ -34,7 +36,7 @@ const LoaderContainer = styled.div((props) => ({
function TransactionRequest() {
const { network, feeMultipliers, accountsList, selectedAccount } = useWalletSelector();
- const { payload, tabId, requestToken, stacksTransaction } = useStxTransactionRequest();
+ const txReq = useStxTransactionRequest();
const navigate = useNavigate();
const selectedNetwork = useNetworkSelector();
const { switchAccount } = useWalletReducer();
@@ -44,6 +46,9 @@ function TransactionRequest() {
const [codeBody, setCodeBody] = useState(undefined);
const [contractName, setContractName] = useState(undefined);
const [attachment, setAttachment] = useState(undefined);
+ const { t } = useTranslation('translation', { keyPrefix: 'REQUEST_ERRORS' });
+ const { payload, tabId, requestToken, transaction } = txReq;
+ const { messageId, rpcMethod } = 'rpcMethod' in txReq ? txReq : { messageId: '', rpcMethod: '' };
const handleTokenTransferRequest = async (tokenTransferPayload: any, requestAccount: Account) => {
const stxPendingTxData = await fetchStxPendingTxData(
@@ -58,7 +63,7 @@ function TransactionRequest() {
feeMultipliers,
selectedNetwork,
stxPendingTxData || [],
- stacksTransaction?.auth,
+ transaction?.auth,
);
setUnsignedTx(unsignedSendStxTx);
navigate('/confirm-stx-tx', {
@@ -67,7 +72,9 @@ function TransactionRequest() {
sponsored: tokenTransferPayload.sponsored,
isBrowserTx: true,
tabId,
+ messageId,
requestToken,
+ rpcMethod,
},
});
};
@@ -85,7 +92,7 @@ function TransactionRequest() {
requestAccount.stxAddress,
selectedNetwork,
requestAccount.stxPublicKey,
- stacksTransaction?.auth,
+ transaction?.auth,
);
setUnsignedTx(unSignedContractCall);
setCoinsMetaData(coinMeta);
@@ -102,8 +109,11 @@ function TransactionRequest() {
state: {
txid: '',
currency: 'STX',
- error: 'Contract function call missing arguments',
+ error: t('MISSING_ARGUMENTS'),
browserTx: true,
+ tabId,
+ messageId,
+ rpcMethod,
},
});
}
@@ -120,7 +130,7 @@ function TransactionRequest() {
requestAccount.stxPublicKey,
feeMultipliers!,
requestAccount.stxAddress,
- stacksTransaction?.auth,
+ transaction?.auth,
);
setUnsignedTx(response.contractDeployTx);
setCodeBody(response.codeBody);
@@ -140,6 +150,8 @@ function TransactionRequest() {
isBrowserTx: true,
tabId,
requestToken,
+ rpcMethod,
+ messageId,
},
});
}
@@ -161,23 +173,29 @@ function TransactionRequest() {
} catch (e: unknown) {
console.error(e); // eslint-disable-line
toast.error('Unexpected error creating transaction');
+ sendInternalErrorMessage({ tabId, messageId });
}
};
const handleRequest = async () => {
- if (getNetworkType(payload.network) !== network.type) {
+ if (payload.network && getNetworkType(payload.network) !== network.type) {
navigate('/tx-status', {
state: {
txid: '',
currency: 'STX',
- error:
- 'There’s a mismatch between your active network and the network you’re logged with.',
+ error: t('NETWORK_MISMATCH'),
browserTx: true,
+ tabId,
+ messageId,
},
});
return;
}
- if (payload.stxAddress !== selectedAccount?.stxAddress && !isHardwareAccount(selectedAccount)) {
+ if (
+ payload.stxAddress &&
+ payload.stxAddress !== selectedAccount?.stxAddress &&
+ !isHardwareAccount(selectedAccount)
+ ) {
const account = accountsList.find((acc) => acc.stxAddress === payload.stxAddress);
if (account) {
await switchAccount(account);
@@ -187,14 +205,16 @@ function TransactionRequest() {
state: {
txid: '',
currency: 'STX',
- error:
- 'There’s a mismatch between your active address and the address you’re logged with.',
+ error: t('ADDRESS_MISMATCH'),
browserTx: true,
+ tabId,
+ messageId,
+ rpcMethod,
},
});
}
} else if (selectedAccount) {
- await createRequestTx(selectedAccount!);
+ await createRequestTx(selectedAccount);
}
};
@@ -209,7 +229,7 @@ function TransactionRequest() {
) : null}
- {payload.txType === 'contract_call' && unsignedTx ? (
+ {payload && payload.txType === 'contract_call' && unsignedTx ? (
) : null}
- {payload.txType === 'smart_contract' && unsignedTx ? (
+ {payload && payload.txType === 'smart_contract' && unsignedTx ? (
) : null}
>
diff --git a/src/app/screens/transactionRequest/useStxTransactionRequest/index.ts b/src/app/screens/transactionRequest/useStxTransactionRequest/index.ts
new file mode 100644
index 000000000..66c7e08d8
--- /dev/null
+++ b/src/app/screens/transactionRequest/useStxTransactionRequest/index.ts
@@ -0,0 +1,170 @@
+import { parseData } from '@common/utils';
+import { callContractParamsSchema } from '@common/utils/rpc/stx/callContract/paramsSchema';
+import { deployContractParamsSchema } from '@common/utils/rpc/stx/deployContract/paramsSchema';
+import useNetworkSelector from '@hooks/useNetwork';
+import useWalletSelector from '@hooks/useWalletSelector';
+import { txPayloadToRequest } from '@secretkeylabs/xverse-core';
+import { ContractCallPayload, ContractDeployPayload, TransactionTypes } from '@stacks/connect';
+import { AuthType, PayloadType, deserializeTransaction } from '@stacks/transactions';
+import { createUnsecuredToken, decodeToken } from 'jsontokens';
+import { useLocation } from 'react-router-dom';
+import { Return } from './types';
+import { getPayload, isDeployContractPayload } from './utils';
+
+const useStxTransactionRequest = (): Return => {
+ // Params
+ const { search } = useLocation();
+ const params = new URLSearchParams(search);
+
+ // Utils
+ const { stxAddress, stxPublicKey } = useWalletSelector();
+ const network = useNetworkSelector();
+
+ // Common to all WebBTC RPC methods
+ const tabId = Number(params.get('tabId')) ?? 0;
+ const messageId = params.get('messageId') ?? '';
+ const rpcMethod = params.get('rpcMethod') ?? '';
+
+ switch (rpcMethod) {
+ case 'stx_transferStx': {
+ const payload = {
+ network,
+ recipient: params.get('recipient'),
+ amount: params.get('amount'),
+ memo: params.get('memo'),
+ txType: 'token_transfer',
+ };
+ return {
+ // Metadata
+ messageId,
+ tabId,
+ rpcMethod,
+
+ // Legacy
+ payload,
+ requestToken: '',
+ };
+ }
+ case 'stx_signTransaction': {
+ const transactionHex = params.get('transaction') ?? '';
+ const transaction = deserializeTransaction(transactionHex);
+
+ let legacyPayload: any;
+ const txPayload = transaction.payload;
+ if (txPayload.payloadType === PayloadType.TokenTransfer) {
+ legacyPayload = {
+ txType: 'token_transfer',
+ recipient: txPayload.recipient,
+ amount: txPayload.amount,
+ memo: txPayload.memo,
+ sponsored: transaction.auth.authType === AuthType.Sponsored,
+ };
+ }
+ if (txPayload.payloadType === PayloadType.ContractCall) {
+ legacyPayload = txPayloadToRequest(transaction, stxAddress);
+ }
+
+ if (isDeployContractPayload(txPayload.payloadType)) {
+ legacyPayload = {
+ network,
+ ...txPayloadToRequest(transaction, stxAddress),
+ };
+ }
+
+ return {
+ // Metadata
+ tabId,
+ messageId,
+ rpcMethod,
+
+ // Legacy
+ payload: legacyPayload,
+ transaction,
+ requestToken: '',
+ };
+ }
+ case 'stx_callContract': {
+ const contract = params.get('contract') ?? '';
+ const functionName = params.get('functionName') ?? '';
+ const argumentsString = params.get('arguments') ?? '';
+ const [error, data] = parseData(argumentsString, callContractParamsSchema.shape.arguments);
+
+ const argumentsArray = error
+ ? (() => {
+ console.error('Error parsing arguments', error);
+ return undefined;
+ })()
+ : data;
+
+ const payload: ContractCallPayload = {
+ txType: TransactionTypes.ContractCall,
+ contractAddress: contract.split('.')[0],
+ contractName: contract.split('.')[1],
+ functionName,
+ functionArgs: argumentsArray ?? [],
+ publicKey: stxPublicKey,
+ network,
+ postConditions: [],
+ };
+ const requestToken = createUnsecuredToken(payload as any);
+
+ return {
+ // Metadata
+ tabId,
+ messageId,
+ rpcMethod,
+
+ // Legacy
+ payload,
+ requestToken,
+ };
+ }
+ case 'stx_deployContract': {
+ const name = params.get('name') ?? '';
+ const clarityCodeParam = params.get('clarityCode') ?? '';
+ const [, clarityCode] = parseData(
+ clarityCodeParam,
+ deployContractParamsSchema.shape.clarityCode,
+ );
+ // Currently unused
+ // const clarityVersion = params.get('clarityVersion') ?? '';
+
+ const payload: ContractDeployPayload = {
+ contractName: name,
+ codeBody: clarityCode ?? '',
+ txType: TransactionTypes.ContractDeploy,
+ publicKey: stxPublicKey,
+ };
+ const requestToken = createUnsecuredToken(payload as any);
+
+ return {
+ // Metadata
+ tabId,
+ messageId,
+ rpcMethod,
+
+ // Legacy
+ payload,
+ requestToken,
+ };
+ }
+ default: {
+ // Assume legacy request
+ const requestToken = params.get('request') ?? '';
+ const decodedToken = decodeToken(requestToken ?? '') as any;
+ const transaction = decodedToken.payload.txHex
+ ? deserializeTransaction(decodedToken.payload.txHex!)
+ : undefined;
+ const txPayload = getPayload({ decodedToken, transaction });
+
+ return {
+ payload: txPayload,
+ transaction,
+ tabId,
+ requestToken,
+ };
+ }
+ }
+};
+
+export default useStxTransactionRequest;
diff --git a/src/app/screens/transactionRequest/useStxTransactionRequest/types.ts b/src/app/screens/transactionRequest/useStxTransactionRequest/types.ts
new file mode 100644
index 000000000..a64564883
--- /dev/null
+++ b/src/app/screens/transactionRequest/useStxTransactionRequest/types.ts
@@ -0,0 +1,35 @@
+import { StacksTransaction } from '@stacks/transactions';
+
+interface TLegacyReturn {
+ payload: any;
+ transaction?: StacksTransaction;
+ tabId: number;
+ requestToken: string;
+}
+
+interface Metadata {
+ messageId: string;
+}
+
+export interface TReturnSignTransaction extends TLegacyReturn, Metadata {
+ rpcMethod: 'stx_signTransaction';
+}
+
+export interface TReturnCallContract extends TLegacyReturn, Metadata {
+ rpcMethod: 'stx_callContract';
+}
+
+export interface TReturnTransferStx extends TLegacyReturn, Metadata {
+ rpcMethod: 'stx_transferStx';
+}
+
+export interface TReturnDeployContract extends TLegacyReturn, Metadata {
+ rpcMethod: 'stx_deployContract';
+}
+
+export type Return =
+ | TReturnSignTransaction
+ | TReturnCallContract
+ | TReturnTransferStx
+ | TReturnDeployContract
+ | TLegacyReturn;
diff --git a/src/app/screens/transactionRequest/useStxTransactionRequest/utils.ts b/src/app/screens/transactionRequest/useStxTransactionRequest/utils.ts
new file mode 100644
index 000000000..a9793a922
--- /dev/null
+++ b/src/app/screens/transactionRequest/useStxTransactionRequest/utils.ts
@@ -0,0 +1,27 @@
+/* eslint-disable import/prefer-default-export */
+import { txPayloadToRequest } from '@secretkeylabs/xverse-core';
+import { PayloadType, StacksTransaction } from '@stacks/transactions';
+
+interface GetPayloadArgs {
+ decodedToken: Record;
+ transaction?: StacksTransaction;
+}
+
+export const getPayload = ({ decodedToken, transaction: stacksTransaction }: GetPayloadArgs) => {
+ if (stacksTransaction) {
+ const txPayload = txPayloadToRequest(
+ stacksTransaction,
+ decodedToken.payload.stxAddress,
+ decodedToken.payload.attachment,
+ );
+ return {
+ ...decodedToken.payload,
+ ...txPayload,
+ };
+ }
+ return decodedToken.payload;
+};
+
+export function isDeployContractPayload(payload: PayloadType) {
+ return [PayloadType.SmartContract, PayloadType.VersionedSmartContract].includes(payload);
+}
diff --git a/src/app/screens/transactionStatus/index.tsx b/src/app/screens/transactionStatus/index.tsx
index 1e397eba8..472767efe 100644
--- a/src/app/screens/transactionStatus/index.tsx
+++ b/src/app/screens/transactionStatus/index.tsx
@@ -1,6 +1,11 @@
import ArrowSquareOut from '@assets/img/arrow_square_out.svg';
import Success from '@assets/img/send/check_circle.svg';
import Failure from '@assets/img/send/x_circle.svg';
+import {
+ sendAddressMismatchMessage,
+ sendMissingFunctionArgumentsMessage,
+ sendNetworkMismatchMessage,
+} from '@common/utils/rpc/stx/rpcResponseMessages';
import ActionButton from '@components/button';
import CopyButton from '@components/copyButton';
import InfoContainer from '@components/infoContainer';
@@ -139,6 +144,7 @@ const Button = styled.button((props) => ({
function TransactionStatus() {
const { t } = useTranslation('translation', { keyPrefix: 'TRANSACTION_STATUS' });
+ const { t: tReqErrors } = useTranslation('translation', { keyPrefix: 'REQUEST_ERRORS' });
const navigate = useNavigate();
const location = useLocation();
const { network } = useWalletSelector();
@@ -156,7 +162,9 @@ function TransactionStatus() {
isBrc20TokenFlow,
isSponsorServiceError,
isSwapTransaction,
- } = location.state;
+ tabId,
+ messageId,
+ } = location.state as { tabId?: chrome.tabs.Tab['id']; messageId?: string; [key: string]: any };
const renderTransactionSuccessStatus = (
@@ -185,8 +193,16 @@ function TransactionStatus() {
};
const onCloseClick = () => {
- if (browserTx) window.close();
- else if (isRareSat) navigate('/nft-dashboard?tab=rareSats');
+ if (browserTx) {
+ if (error === tReqErrors('NETWORK_MISMATCH') && tabId && messageId)
+ sendNetworkMismatchMessage({ tabId, messageId });
+ if (error === tReqErrors('ADDRESS_MISMATCH') && tabId && messageId)
+ sendAddressMismatchMessage({ tabId, messageId });
+ if (error === tReqErrors('MISSING_ARGUMENTS') && tabId && messageId)
+ sendMissingFunctionArgumentsMessage({ tabId, messageId });
+
+ window.close();
+ } else if (isRareSat) navigate('/nft-dashboard?tab=rareSats');
else if (isOrdinal) navigate('/nft-dashboard?tab=inscriptions');
else if (isNft) navigate('/nft-dashboard?tab=nfts');
else navigate('/');
diff --git a/src/app/stores/wallet/actions/actionCreators.ts b/src/app/stores/wallet/actions/actionCreators.ts
index aadfc5c52..234424366 100644
--- a/src/app/stores/wallet/actions/actionCreators.ts
+++ b/src/app/stores/wallet/actions/actionCreators.ts
@@ -253,6 +253,14 @@ export const setRunesManageTokensAction = (params: {
...params,
});
+export const setNotificationBannersAction = (params: {
+ id: string;
+ isDismissed: boolean;
+}): actions.SetNotificationBanners => ({
+ type: actions.SetNotificationBannersKey,
+ ...params,
+});
+
export function setWalletLockPeriodAction(
walletLockPeriod: actions.WalletSessionPeriods,
): actions.SetWalletLockPeriod {
diff --git a/src/app/stores/wallet/actions/types.ts b/src/app/stores/wallet/actions/types.ts
index 6dd308989..0cf53bb46 100644
--- a/src/app/stores/wallet/actions/types.ts
+++ b/src/app/stores/wallet/actions/types.ts
@@ -33,6 +33,7 @@ export const UpdateLedgerAccountsKey = 'UpdateLedgerAccountsKey';
export const SetSip10ManageTokensKey = 'SetSip10ManageTokensKey';
export const SetBrc20ManageTokensKey = 'SetBrc20ManageTokensKey';
export const SetRunesManageTokensKey = 'SetRunesManageTokens';
+export const SetNotificationBannersKey = 'SetNotificationBanners';
export const SetWalletLockPeriodKey = 'SetWalletLockPeriod';
export const SetWalletUnlockedKey = 'SetWalletUnlocked';
export const RenameAccountKey = 'RenameAccountKey';
@@ -71,6 +72,7 @@ export interface WalletState {
sip10ManageTokens: Record;
brc20ManageTokens: Record;
runesManageTokens: Record;
+ notificationBanners: Record;
feeMultipliers: AppInfo | null;
hasActivatedOrdinalsKey: boolean | undefined;
hasActivatedRareSatsKey: boolean | undefined;
@@ -224,6 +226,12 @@ export interface SetRunesManageTokens {
isEnabled: boolean;
}
+export interface SetNotificationBanners {
+ type: typeof SetNotificationBannersKey;
+ id: string;
+ isDismissed: boolean;
+}
+
export interface SetWalletLockPeriod {
type: typeof SetWalletLockPeriodKey;
walletLockPeriod: WalletSessionPeriods;
@@ -274,6 +282,7 @@ export type WalletActions =
| SetSip10ManageTokens
| SetBrc20ManageTokens
| SetRunesManageTokens
+ | SetNotificationBanners
| SetWalletLockPeriod
| SetRareSatsNoticeDismissed
| SetWalletUnlocked
diff --git a/src/app/stores/wallet/reducer.ts b/src/app/stores/wallet/reducer.ts
index 3730a504f..f9613eb6d 100644
--- a/src/app/stores/wallet/reducer.ts
+++ b/src/app/stores/wallet/reducer.ts
@@ -20,6 +20,7 @@ import {
SetBtcWalletDataKey,
SetCoinRatesKey,
SetFeeMultiplierKey,
+ SetNotificationBannersKey,
SetRunesManageTokensKey,
SetSip10ManageTokensKey,
SetStxWalletDataKey,
@@ -90,6 +91,7 @@ export const initialWalletState: WalletState = {
sip10ManageTokens: {},
brc20ManageTokens: {},
runesManageTokens: {},
+ notificationBanners: {},
feeMultipliers: null,
hasActivatedOrdinalsKey: undefined,
hasActivatedRareSatsKey: undefined,
@@ -269,6 +271,14 @@ const walletReducer = (
[action.principal]: action.isEnabled,
},
};
+ case SetNotificationBannersKey:
+ return {
+ ...state,
+ notificationBanners: {
+ ...state.notificationBanners,
+ [action.id]: action.isDismissed,
+ },
+ };
case SetWalletLockPeriodKey:
return {
...state,
diff --git a/src/app/ui-library/button.tsx b/src/app/ui-library/button.tsx
index 29ae57041..3b720d8b3 100644
--- a/src/app/ui-library/button.tsx
+++ b/src/app/ui-library/button.tsx
@@ -7,9 +7,12 @@ type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'danger';
const StyledButton = styled.button`
width: 100%;
user-select: none;
+ transition: all 0.1s ease;
+ min-height: 44px;
display: flex;
justify-content: center;
+ align-items: center;
gap: ${(props) => props.theme.space.xs};
padding: ${(props) => props.theme.space.s} ${(props) => props.theme.space.m};
@@ -24,6 +27,7 @@ const StyledButton = styled.button`
&.primary {
background-color: ${(props) => props.theme.colors.white_0};
+ border: 1px solid;
color: ${(props) => props.theme.colors.elevation0};
:focus:enabled,
diff --git a/src/app/ui-library/divider.tsx b/src/app/ui-library/divider.tsx
index fd852ffd7..2f6b5df11 100644
--- a/src/app/ui-library/divider.tsx
+++ b/src/app/ui-library/divider.tsx
@@ -1,11 +1,16 @@
import styled from 'styled-components';
import Theme from 'theme';
-const Divider = styled.div<{ verticalMargin: keyof typeof Theme.space }>((props) => ({
+const Divider = styled.div<{
+ verticalMargin: keyof typeof Theme.space;
+ color?: keyof typeof Theme.colors;
+}>((props) => ({
display: 'flex',
width: '100%',
height: 1,
- backgroundColor: props.theme.colors.white_900,
+ backgroundColor: props.color
+ ? String(props.theme.colors[props.color])
+ : props.theme.colors.white_900,
margin: `${props.theme.space[props.verticalMargin]} 0`,
}));
export default Divider;
diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts
index 631111102..da50595d7 100644
--- a/src/app/utils/constants.ts
+++ b/src/app/utils/constants.ts
@@ -1,5 +1,6 @@
/* eslint-disable prefer-destructuring */
import type { NetworkType } from '@secretkeylabs/xverse-core';
+import { Provider } from 'sats-connect';
export const GAMMA_URL = 'https://gamma.io/';
export const TERMS_LINK = 'https://xverse.app/terms';
@@ -12,6 +13,7 @@ export const BTC_TRANSACTION_TESTNET_STATUS_URL = 'https://mempool.space/testnet
export const TRANSACTION_STATUS_URL = 'https://explorer.stacks.co/txid/';
export const XVERSE_WEB_POOL_URL = 'https://pool.xverse.app';
export const XVERSE_EXPLORE_URL = 'https://wallet.xverse.app/explore';
+export const XVERSE_POOL_ADDRESS = 'SPXVRSEH2BKSXAEJ00F1BY562P45D5ERPSKR4Q33';
export const XVERSE_ORDIVIEW_URL = (network: NetworkType) =>
`https://ord${network === 'Mainnet' ? '' : '-testnet'}.xverse.app`;
@@ -21,6 +23,7 @@ export const TRANSAC_API_KEY = process.env.TRANSAC_API_KEY;
export const MOON_PAY_URL = 'https://buy.moonpay.com';
export const MOON_PAY_API_KEY = process.env.MOON_PAY_API_KEY;
export const MIX_PANEL_TOKEN = process.env.MIX_PANEL_TOKEN;
+export const MIX_PANEL_EXPLORE_APP_TOKEN = process.env.MIX_PANEL_EXPLORE_APP_TOKEN;
export type CurrencyTypes = 'STX' | 'BTC' | 'FT' | 'NFT' | 'Ordinal' | 'brc20-Ordinal' | 'RareSat';
export enum LoaderSize {
@@ -59,3 +62,30 @@ export const MAX_ACC_NAME_LENGTH = 20;
// UI
export const EMPTY_LABEL = '--';
+
+export const XverseProviderInfo: Provider = {
+ id: 'XverseProviders.BitcoinProvider',
+ name: 'Xverse Wallet',
+ icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGZpbGw9IiMxNzE3MTciIGQ9Ik0wIDBoNjAwdjYwMEgweiIvPjxwYXRoIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0ibm9uemVybyIgZD0iTTQ0MCA0MzUuNHYtNTFjMC0yLS44LTMuOS0yLjItNS4zTDIyMCAxNjIuMmE3LjYgNy42IDAgMCAwLTUuNC0yLjJoLTUxLjFjLTIuNSAwLTQuNiAyLTQuNiA0LjZ2NDcuM2MwIDIgLjggNCAyLjIgNS40bDc4LjIgNzcuOGE0LjYgNC42IDAgMCAxIDAgNi41bC03OSA3OC43Yy0xIC45LTEuNCAyLTEuNCAzLjJ2NTJjMCAyLjQgMiA0LjUgNC42IDQuNUgyNDljMi42IDAgNC42LTIgNC42LTQuNlY0MDVjMC0xLjIuNS0yLjQgMS40LTMuM2w0Mi40LTQyLjJhNC42IDQuNiAwIDAgMSA2LjQgMGw3OC43IDc4LjRhNy42IDcuNiAwIDAgMCA1LjQgMi4yaDQ3LjVjMi41IDAgNC42LTIgNC42LTQuNloiLz48cGF0aCBmaWxsPSIjRUU3QTMwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGQ9Ik0zMjUuNiAyMjcuMmg0Mi44YzIuNiAwIDQuNiAyLjEgNC42IDQuNnY0Mi42YzAgNCA1IDYuMSA4IDMuMmw1OC43LTU4LjVjLjgtLjggMS4zLTIgMS4zLTMuMnYtNTEuMmMwLTIuNi0yLTQuNi00LjYtNC42TDM4NCAxNjBjLTEuMiAwLTIuNC41LTMuMyAxLjNsLTU4LjQgNTguMWE0LjYgNC42IDAgMCAwIDMuMiA3LjhaIi8+PC9nPjwvc3ZnPg==',
+ webUrl: 'https://www.xverse.app/',
+ chromeWebStoreUrl:
+ 'https://chrome.google.com/webstore/detail/xverse-wallet/idnnbdplmphpflfnlkomgpfbpcgelopg?hl=en-GB&authuser=1',
+ googlePlayStoreUrl: 'https://play.google.com/store/apps/details?id=com.secretkeylabs.xverse',
+ iOSAppStoreUrl: 'https://apps.apple.com/app/xverse-bitcoin-web3-wallet/id1552272513',
+ methods: [
+ 'getInfo',
+ 'getAddresses',
+ 'getAccounts',
+ 'signMessage',
+ 'sendTransfer',
+ 'signPsbt',
+ 'stx_callContract',
+ 'stx_deployContract',
+ 'stx_getAccounts',
+ 'stx_getAddresses',
+ 'stx_signMessage',
+ 'stx_signStructuredMessage',
+ 'stx_signTransaction',
+ 'stx_transferStx',
+ ],
+};
diff --git a/src/app/utils/mixpanel.ts b/src/app/utils/mixpanel.ts
index 90a254ec9..ecbd7b9dc 100644
--- a/src/app/utils/mixpanel.ts
+++ b/src/app/utils/mixpanel.ts
@@ -1,53 +1,48 @@
import { AnalyticsEvents } from '@secretkeylabs/xverse-core';
+import { getMixpanelInstance, mixpanelInstances } from 'app/mixpanelSetup';
import { sha256 } from 'js-sha256';
-import mixpanel from 'mixpanel-browser';
-import { MIX_PANEL_TOKEN } from './constants';
-export const isMixPanelInited = () => !!MIX_PANEL_TOKEN && !!mixpanel.config;
-
-export const trackMixPanel = (event: string, properties?: any, options?: any, callback?: any) => {
- if (!isMixPanelInited()) {
- return;
- }
-
- mixpanel.track(event, properties, options, callback);
+export const trackMixPanel = (
+ event: string,
+ properties?: any,
+ options?: any,
+ callback?: any,
+ instanceKey: keyof typeof mixpanelInstances = 'web-extension',
+) => {
+ getMixpanelInstance(instanceKey).track(event, properties, options, callback);
};
export const optOutMixPanel = () => {
- if (!isMixPanelInited()) {
- return;
- }
-
- trackMixPanel(AnalyticsEvents.OptOut, undefined, { send_immediately: true }, () => {
- mixpanel.opt_out_tracking();
+ Object.keys(mixpanelInstances).forEach((instanceKey) => {
+ trackMixPanel(
+ AnalyticsEvents.OptOut,
+ undefined,
+ { send_immediately: true },
+ () => {
+ getMixpanelInstance(instanceKey).opt_out_tracking();
+ },
+ instanceKey,
+ );
});
};
export const optInMixPanel = (masterPubKey?: string) => {
- if (!isMixPanelInited()) {
- return;
- }
+ Object.keys(mixpanelInstances).forEach((instanceKey) => {
+ getMixpanelInstance(instanceKey).opt_in_tracking();
- mixpanel.opt_in_tracking();
-
- if (masterPubKey) {
- mixpanel.identify(sha256(masterPubKey));
- }
+ if (masterPubKey) {
+ getMixpanelInstance(instanceKey).identify(sha256(masterPubKey));
+ }
+ });
};
-export const hasOptedInMixPanelTracking = async () => {
- if (!isMixPanelInited()) {
- return false;
- }
-
- const hasOptedIn = await mixpanel.has_opted_in_tracking();
- return hasOptedIn;
-};
+export const hasOptedInMixPanelTracking = () =>
+ Object.keys(mixpanelInstances).every((instanceKey) =>
+ getMixpanelInstance(instanceKey).has_opted_in_tracking(),
+ );
export const resetMixPanel = () => {
- if (!isMixPanelInited()) {
- return;
- }
-
- mixpanel.reset();
+ Object.keys(mixpanelInstances).forEach((instanceKey) => {
+ getMixpanelInstance(instanceKey).reset();
+ });
};
diff --git a/src/assets/img/dashboard/SIP10.svg b/src/assets/img/dashboard/SIP10.svg
deleted file mode 100644
index 8c1f99b38..000000000
--- a/src/assets/img/dashboard/SIP10.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-
diff --git a/src/assets/img/nftDashboard/rareSats/b9450.png b/src/assets/img/nftDashboard/rareSats/b9_450.png
similarity index 100%
rename from src/assets/img/nftDashboard/rareSats/b9450.png
rename to src/assets/img/nftDashboard/rareSats/b9_450.png
diff --git a/src/assets/img/nftDashboard/rareSats/legacy.png b/src/assets/img/nftDashboard/rareSats/legacy.png
new file mode 100644
index 000000000..b6c9b27bc
Binary files /dev/null and b/src/assets/img/nftDashboard/rareSats/legacy.png differ
diff --git a/src/assets/img/nftDashboard/rune_icon.svg b/src/assets/img/nftDashboard/rune_icon.svg
new file mode 100644
index 000000000..341aef2c7
--- /dev/null
+++ b/src/assets/img/nftDashboard/rune_icon.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/img/receive_stx_image.svg b/src/assets/img/receive_stx_image.svg
deleted file mode 100644
index 00478c248..000000000
--- a/src/assets/img/receive_stx_image.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/src/assets/img/stacking/token_ticker.svg b/src/assets/img/stacking/token_ticker.svg
deleted file mode 100644
index ee81043f2..000000000
--- a/src/assets/img/stacking/token_ticker.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/src/background/background.ts b/src/background/background.ts
index cdef79bef..9b96f1873 100755
--- a/src/background/background.ts
+++ b/src/background/background.ts
@@ -1,22 +1,27 @@
/* eslint-disable no-void */
+import type { LegacyMessageFromContentScript, WebBtcMessage } from '@common/types/message-types';
+import { CONTENT_SCRIPT_PORT } from '@common/types/message-types';
import {
handleLegacyExternalMethodFormat,
- inferLegacyMessage,
+ isLegacyMessage,
} from '@common/utils/legacy-external-message-handler';
-import { CONTENT_SCRIPT_PORT } from '@common/types/message-types';
-import type { LegacyMessageFromContentScript } from '@common/types/message-types';
import internalBackgroundMessageHandler from '@common/utils/messageHandlers';
+import handleRPCRequest from '@common/utils/rpc';
+import { Requests } from 'sats-connect';
// Listen for connection to the content-script - port for two-way communication
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== CONTENT_SCRIPT_PORT) return;
- port.onMessage.addListener((message: LegacyMessageFromContentScript, messagingPort) => {
- if (inferLegacyMessage(message)) {
- void handleLegacyExternalMethodFormat(message, messagingPort);
- // eslint-disable-next-line no-useless-return
- return;
- }
- });
+ port.onMessage.addListener(
+ (message: LegacyMessageFromContentScript | WebBtcMessage, messagingPort) => {
+ if (isLegacyMessage(message)) {
+ void handleLegacyExternalMethodFormat(message, messagingPort);
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ void handleRPCRequest(message, port);
+ },
+ );
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -25,6 +30,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
});
-if (process.env.NODE_ENV === 'development') {
+chrome.runtime.onInstalled.addListener((details) => {
+ if (details.reason === 'install') {
+ chrome.tabs.create({ url: chrome.runtime.getURL('options.html#/landing') });
+ }
+});
+
+if (process.env.WALLET_LABEL) {
+ chrome.action.setBadgeText({ text: process.env.WALLET_LABEL });
+} else if (process.env.NODE_ENV === 'development') {
chrome.action.setBadgeText({ text: 'DEV' });
}
diff --git a/src/common/types/inpage-types.ts b/src/common/types/inpage-types.ts
index e124ff834..3e817d29d 100644
--- a/src/common/types/inpage-types.ts
+++ b/src/common/types/inpage-types.ts
@@ -1,18 +1,21 @@
+import { RpcBase } from 'sats-connect';
+
/**
- * Inpage Script (Stacks Provider) <-> Content Script
+ * Inpage Script (Stacks Provider / BitcoinProvider) <-> Content Script
*/
export enum DomEventName {
- authenticationRequest = 'stacksAuthenticationRequest',
- signatureRequest = 'signatureRequest',
- structuredDataSignatureRequest = 'structuredDataSignatureRequest',
- transactionRequest = 'stacksTransactionRequest',
- getAddressRequest = 'SatsAddressRequest',
- signPsbtRequest = 'SatsPsbtRequest',
- signBatchPsbtRequest = 'SatsBatchPsbtRequest',
- signMessageRequest = 'SatsSignMessage',
- sendBtcRequest = 'SatsSendBtcRequest',
- createInscriptionRequest = 'SatsCreateInscriptionRequest',
- createRepeatInscriptionsRequest = 'SatsCreateRepeatInscriptionsRequest',
+ authenticationRequest = 'xverse_stx_authentication_request',
+ signatureRequest = 'xverse_stx_signature_request',
+ structuredDataSignatureRequest = 'xverse_stx_structured_data_signature_request',
+ transactionRequest = 'xverse_stx_transaction_request',
+ getAddressRequest = 'xverse_btc_address_request',
+ signPsbtRequest = 'xverse_btc_sats_psbt_request',
+ signBatchPsbtRequest = 'xverse_btc_batch_psbt_request',
+ signMessageRequest = 'xverse_btc_sign_message_request',
+ sendBtcRequest = 'xverse_btc_send_request',
+ createInscriptionRequest = 'xverse_btc_create_inscription_Request',
+ createRepeatInscriptionsRequest = 'xverse_btc_create_repeat_inscriptions_request',
+ rpcRequest = 'xverse_rpc_request',
}
export interface AuthenticationRequestEventDetails {
@@ -74,3 +77,8 @@ export interface CreateRepeatInscriptionsEventDetails {
}
export type CreateRepeatInscriptionsEvent = CustomEvent;
+
+export interface RpcRequest extends RpcBase {
+ method: T;
+ params: U;
+}
diff --git a/src/common/types/message-types.ts b/src/common/types/message-types.ts
index 756beb028..114fb044d 100644
--- a/src/common/types/message-types.ts
+++ b/src/common/types/message-types.ts
@@ -3,19 +3,22 @@ import {
CreateInscriptionResponse,
CreateRepeatInscriptionsResponse,
GetAddressResponse,
+ RpcId,
SignMultipleTransactionsResponse,
SignTransactionResponse,
+ type Params,
+ type Requests,
} from 'sats-connect';
-export const MESSAGE_SOURCE = 'xverse-wallet' as const;
+export const MESSAGE_SOURCE = 'xverse-wallet';
-export const CONTENT_SCRIPT_PORT = 'xverse-content-script' as const;
+export const CONTENT_SCRIPT_PORT = 'xverse-content-script';
/**
* Stacks External Callable Methods
* @enum {string}
*/
-export enum ExternalMethods {
+export enum StacksLegacyMethods {
transactionRequest = 'transactionRequest',
transactionResponse = 'transactionResponse',
authenticationRequest = 'authenticationRequest',
@@ -26,15 +29,19 @@ export enum ExternalMethods {
structuredDataSignatureResponse = 'structuredDataSignatureResponse',
}
+export enum RpcMethods {
+ request = 'request',
+}
+
export enum InternalMethods {
- RequestDerivedStxAccounts = 'RequestDerivedStxAccounts',
- ShareInMemoryKeyToBackground = 'ShareInMemoryKeyToBackground',
- RequestInMemoryKeys = 'RequestInMemoryKeys',
- RemoveInMemoryKeys = 'RemoveInMemoryKeys',
OriginatingTabClosed = 'OriginatingTabClosed',
}
-export type ExtensionMethods = ExternalMethods | ExternalSatsMethods | InternalMethods;
+export type ExtensionMethods =
+ | StacksLegacyMethods
+ | SatsConnectMethods
+ | InternalMethods
+ | RpcMethods;
interface BaseMessage {
source: typeof MESSAGE_SOURCE;
@@ -50,20 +57,20 @@ export interface Message
payload: Payload;
}
-type AuthenticationRequestMessage = Message;
+type AuthenticationRequestMessage = Message;
export type AuthenticationResponseMessage = Message<
- ExternalMethods.authenticationResponse,
+ StacksLegacyMethods.authenticationResponse,
{
authenticationRequest: string;
authenticationResponse: string;
}
>;
-type SignatureRequestMessage = Message;
+type SignatureRequestMessage = Message;
export type SignatureResponseMessage = Message<
- ExternalMethods.signatureResponse,
+ StacksLegacyMethods.signatureResponse,
{
signatureRequest: string;
signatureResponse: SignatureData | string;
@@ -71,16 +78,16 @@ export type SignatureResponseMessage = Message<
>;
type StructuredDataSignatureRequestMessage = Message<
- ExternalMethods.structuredDataSignatureRequest,
+ StacksLegacyMethods.structuredDataSignatureRequest,
string
>;
-type TransactionRequestMessage = Message;
+type TransactionRequestMessage = Message;
export type TxResult = SponsoredFinishedTxPayload | FinishedTxPayload;
export type TransactionResponseMessage = Message<
- ExternalMethods.transactionResponse,
+ StacksLegacyMethods.transactionResponse,
{
transactionRequest: string;
transactionResponse: TxResult | string;
@@ -102,7 +109,7 @@ export type LegacyMessageToContentScript =
* Sats External Callable Methods
* @enum {string}
*/
-export enum ExternalSatsMethods {
+export enum SatsConnectMethods {
getAddressRequest = 'getAddressRequest',
getAddressResponse = 'getAddressResponse',
signPsbtRequest = 'signPsbtRequest',
@@ -117,24 +124,25 @@ export enum ExternalSatsMethods {
createInscriptionResponse = 'createInscriptionResponse',
createRepeatInscriptionsRequest = 'createRepeatInscriptionsRequest',
createRepeatInscriptionsResponse = 'createRepeatInscriptionsResponse',
+ request = 'request',
}
-type GetAddressRequestMessage = Message;
+type GetAddressRequestMessage = Message;
export type GetAddressResponseMessage = Message<
- ExternalSatsMethods.getAddressResponse,
+ SatsConnectMethods.getAddressResponse,
{
addressRequest: string;
addressResponse: GetAddressResponse | string;
}
>;
-type SignPsbtRequestMessage = Message;
+type SignPsbtRequestMessage = Message;
-type SignBatchPsbtRequestMessage = Message;
+type SignBatchPsbtRequestMessage = Message;
export type SignPsbtResponseMessage = Message<
- ExternalSatsMethods.signPsbtResponse,
+ SatsConnectMethods.signPsbtResponse,
{
signPsbtRequest: string;
signPsbtResponse: SignTransactionResponse | string;
@@ -142,40 +150,37 @@ export type SignPsbtResponseMessage = Message<
>;
export type SignBatchPsbtResponseMessage = Message<
- ExternalSatsMethods.signBatchPsbtResponse,
+ SatsConnectMethods.signBatchPsbtResponse,
{
signBatchPsbtRequest: string;
signBatchPsbtResponse: SignMultipleTransactionsResponse | string;
}
>;
-type SignMessageRequestMessage = Message;
+type SignMessageRequestMessage = Message;
export type SignMessageResponseMessage = Message<
- ExternalSatsMethods.signMessageResponse,
+ SatsConnectMethods.signMessageResponse,
{
signMessageRequest: string;
signMessageResponse: string;
}
>;
-type SendBtcRequestMessage = Message;
+type SendBtcRequestMessage = Message;
export type SendBtcResponseMessage = Message<
- ExternalSatsMethods.sendBtcResponse,
+ SatsConnectMethods.sendBtcResponse,
{
sendBtcRequest: string;
sendBtcResponse: string;
}
>;
-type CreateInscriptionRequestMessage = Message<
- ExternalSatsMethods.createInscriptionRequest,
- string
->;
+type CreateInscriptionRequestMessage = Message;
export type CreateInscriptionResponseMessage = Message<
- ExternalSatsMethods.createInscriptionResponse,
+ SatsConnectMethods.createInscriptionResponse,
{
createInscriptionRequest: string;
createInscriptionResponse: CreateInscriptionResponse | string;
@@ -183,18 +188,24 @@ export type CreateInscriptionResponseMessage = Message<
>;
type CreateRepeatInscriptionsRequestMessage = Message<
- ExternalSatsMethods.createRepeatInscriptionsRequest,
+ SatsConnectMethods.createRepeatInscriptionsRequest,
string
>;
export type CreateRepeatInscriptionsResponseMessage = Message<
- ExternalSatsMethods.createRepeatInscriptionsResponse,
+ SatsConnectMethods.createRepeatInscriptionsResponse,
{
createRepeatInscriptionsRequest: string;
createRepeatInscriptionsResponse: CreateRepeatInscriptionsResponse | string;
}
>;
+export type WebBtcMessage = {
+ id: RpcId;
+ method: Method;
+ params: Params;
+};
+
export type SatsConnectMessageFromContentScript =
| GetAddressRequestMessage
| SignPsbtRequestMessage
diff --git a/src/common/types/messages.ts b/src/common/types/messages.ts
index b5904695b..263d7a288 100644
--- a/src/common/types/messages.ts
+++ b/src/common/types/messages.ts
@@ -8,25 +8,12 @@ type BackgroundMessage = Omit
'source'
>;
-type ShareInMemoryKeyToBackground = BackgroundMessage<
- InternalMethods.ShareInMemoryKeyToBackground,
- { secretKey: string }
->;
-
-type RequestInMemoryKeys = BackgroundMessage;
-
-type RemoveInMemoryKeys = BackgroundMessage;
-
type OriginatingTabClosed = BackgroundMessage<
InternalMethods.OriginatingTabClosed,
{ tabId: number }
>;
-export type BackgroundMessages =
- | ShareInMemoryKeyToBackground
- | RequestInMemoryKeys
- | RemoveInMemoryKeys
- | OriginatingTabClosed;
+export type BackgroundMessages = OriginatingTabClosed;
export function sendMessage(message: BackgroundMessages) {
return chrome.runtime.sendMessage(message);
diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts
new file mode 100644
index 000000000..1f355504f
--- /dev/null
+++ b/src/common/utils/index.ts
@@ -0,0 +1,36 @@
+import { createUnsecuredToken, decodeToken } from 'jsontokens';
+import { parse, stringify } from 'superjson';
+import { ZodSchema } from 'zod';
+
+export function getTabIdFromPort(port: chrome.runtime.Port) {
+ return port.sender?.tab?.id ?? 0;
+}
+
+export function isUndefined(value: unknown): value is undefined {
+ return typeof value === 'undefined';
+}
+
+type ExtractedParam = ReturnType;
+export function getParams>(
+ paramNames: T,
+ params: URLSearchParams,
+): Record {
+ const obj: Record = {} as Record;
+
+ paramNames.forEach((name) => {
+ obj[name] = params.get(name);
+ });
+
+ return obj;
+}
+
+export function stringifyData(data: unknown) {
+ return createUnsecuredToken(stringify(data));
+}
+
+export function parseData(stringifiedData: string, schema: ZodSchema) {
+ const parseResult = schema.safeParse(parse(decodeToken(stringifiedData).payload as any));
+ if (!parseResult.success) return [parseResult.error, null] as const;
+
+ return [null, parseResult.data] as const;
+}
diff --git a/src/common/utils/legacy-external-message-handler.ts b/src/common/utils/legacy-external-message-handler.ts
index 44ddfc39b..dcb925e37 100644
--- a/src/common/utils/legacy-external-message-handler.ts
+++ b/src/common/utils/legacy-external-message-handler.ts
@@ -1,46 +1,56 @@
import { SignatureData } from '@stacks/connect';
+import { RpcErrorResponse } from 'sats-connect';
+import { getTabIdFromPort } from '.';
import {
- ExternalMethods,
- ExternalSatsMethods,
InternalMethods,
LegacyMessageFromContentScript,
LegacyMessageToContentScript,
MESSAGE_SOURCE,
SatsConnectMessageFromContentScript,
SatsConnectMessageToContentScript,
+ SatsConnectMethods,
SignatureResponseMessage,
+ StacksLegacyMethods,
} from '../types/message-types';
import { sendMessage } from '../types/messages';
import popupCenter from './popup-center';
import RequestsRoutes from './route-urls';
-export function inferLegacyMessage(message: any): message is LegacyMessageFromContentScript {
+export function isLegacyMessage(message: any): message is LegacyMessageFromContentScript {
// Now that we use a RPC communication style, we can infer
// legacy message types by presence of an id
const hasIdProp = 'id' in message;
return !hasIdProp;
}
-function getTabIdFromPort(port: chrome.runtime.Port) {
- return port.sender?.tab?.id;
-}
-
function getOriginFromPort(port: chrome.runtime.Port) {
if (port.sender?.url) return new URL(port.sender.url).origin;
return port.sender?.origin;
}
-function makeSearchParamsWithDefaults(
- port: chrome.runtime.Port,
- otherParams: [string, string][] = [],
-) {
+export type ParamsObject = Record;
+export type ParamsKeyValueArray = [string, string | null | undefined][];
+
+export type Params = ParamsObject | ParamsKeyValueArray;
+export function makeSearchParamsWithDefaults(port: chrome.runtime.Port, additionalParams?: Params) {
const urlParams = new URLSearchParams();
// All actions must have a corresponding `origin` and `tabId`
const origin = getOriginFromPort(port);
const tabId = getTabIdFromPort(port);
urlParams.set('origin', origin ?? '');
urlParams.set('tabId', tabId?.toString() ?? '');
- otherParams.forEach(([key, value]) => urlParams.set(key, value));
+
+ let additionalParamsEntries: ParamsKeyValueArray = [];
+ if (Array.isArray(additionalParams)) {
+ additionalParamsEntries = additionalParams;
+ } else if (typeof additionalParams === 'object') {
+ additionalParamsEntries = Object.entries(additionalParams);
+ }
+
+ additionalParamsEntries.forEach(
+ ([key, value]) => typeof value === 'string' && urlParams.set(key, value),
+ );
+
return { urlParams, origin, tabId };
}
@@ -49,9 +59,9 @@ interface ListenForPopupCloseArgs {
id?: number;
// TabID from requesting tab, to which request should be returned
tabId?: number;
- response: LegacyMessageToContentScript | SatsConnectMessageToContentScript;
+ response: LegacyMessageToContentScript | SatsConnectMessageToContentScript | RpcErrorResponse;
}
-function listenForPopupClose({ id, tabId, response }: ListenForPopupCloseArgs) {
+export function listenForPopupClose({ id, tabId, response }: ListenForPopupCloseArgs) {
chrome.windows.onRemoved.addListener((winId) => {
if (winId !== id || !tabId) return;
const responseMessage = response;
@@ -69,7 +79,7 @@ export function formatMessageSigningResponse({
}: FormatMessageSigningResponseArgs): SignatureResponseMessage {
return {
source: MESSAGE_SOURCE,
- method: ExternalMethods.signatureResponse,
+ method: StacksLegacyMethods.signatureResponse,
payload: { signatureRequest: request, signatureResponse: response },
};
}
@@ -77,14 +87,14 @@ export function formatMessageSigningResponse({
interface ListenForOriginTabCloseArgs {
tabId?: number;
}
-function listenForOriginTabClose({ tabId }: ListenForOriginTabCloseArgs) {
+export function listenForOriginTabClose({ tabId }: ListenForOriginTabCloseArgs) {
chrome.tabs.onRemoved.addListener((closedTabId) => {
if (tabId !== closedTabId) return;
sendMessage({ method: InternalMethods.OriginatingTabClosed, payload: { tabId } });
});
}
-async function triggerRequestWindowOpen(path: RequestsRoutes, urlParams: URLSearchParams) {
+export async function triggerRequestWindowOpen(path: RequestsRoutes, urlParams: URLSearchParams) {
return popupCenter({ url: `/popup.html#${path}?${urlParams.toString()}` });
}
@@ -94,7 +104,7 @@ export async function handleLegacyExternalMethodFormat(
) {
const { payload } = message;
switch (message.method) {
- case ExternalMethods.authenticationRequest: {
+ case StacksLegacyMethods.authenticationRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [['authRequest', payload]]);
const { id } = await triggerRequestWindowOpen(
RequestsRoutes.AuthenticationRequest,
@@ -109,14 +119,14 @@ export async function handleLegacyExternalMethodFormat(
authenticationRequest: payload,
authenticationResponse: 'cancel',
},
- method: ExternalMethods.authenticationResponse,
+ method: StacksLegacyMethods.authenticationResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalMethods.transactionRequest: {
+ case StacksLegacyMethods.transactionRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [['request', payload]]);
const { id } = await triggerRequestWindowOpen(RequestsRoutes.TransactionRequest, urlParams);
@@ -125,7 +135,7 @@ export async function handleLegacyExternalMethodFormat(
tabId,
response: {
source: MESSAGE_SOURCE,
- method: ExternalMethods.transactionResponse,
+ method: StacksLegacyMethods.transactionResponse,
payload: {
transactionRequest: payload,
transactionResponse: 'cancel',
@@ -136,7 +146,7 @@ export async function handleLegacyExternalMethodFormat(
break;
}
- case ExternalMethods.signatureRequest: {
+ case StacksLegacyMethods.signatureRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['request', payload],
['messageType', 'utf8'],
@@ -152,7 +162,7 @@ export async function handleLegacyExternalMethodFormat(
break;
}
- case ExternalMethods.structuredDataSignatureRequest: {
+ case StacksLegacyMethods.structuredDataSignatureRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['request', payload],
['messageType', 'structured'],
@@ -167,7 +177,7 @@ export async function handleLegacyExternalMethodFormat(
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.getAddressRequest: {
+ case SatsConnectMethods.getAddressRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['addressRequest', payload],
]);
@@ -182,13 +192,13 @@ export async function handleLegacyExternalMethodFormat(
addressRequest: payload,
addressResponse: 'cancel',
},
- method: ExternalSatsMethods.getAddressResponse,
+ method: SatsConnectMethods.getAddressResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.signPsbtRequest: {
+ case SatsConnectMethods.signPsbtRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['signPsbtRequest', payload],
]);
@@ -203,13 +213,13 @@ export async function handleLegacyExternalMethodFormat(
signPsbtRequest: payload,
signPsbtResponse: 'cancel',
},
- method: ExternalSatsMethods.signPsbtResponse,
+ method: SatsConnectMethods.signPsbtResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.signBatchPsbtRequest: {
+ case SatsConnectMethods.signBatchPsbtRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['signBatchPsbtRequest', payload],
]);
@@ -224,18 +234,18 @@ export async function handleLegacyExternalMethodFormat(
signBatchPsbtRequest: payload,
signBatchPsbtResponse: 'cancel',
},
- method: ExternalSatsMethods.signBatchPsbtResponse,
+ method: SatsConnectMethods.signBatchPsbtResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.signMessageRequest: {
+ case SatsConnectMethods.signMessageRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['signMessageRequest', payload],
]);
- const { id } = await triggerRequestWindowOpen(RequestsRoutes.SignatureRequest, urlParams);
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.SignMessageRequest, urlParams);
listenForPopupClose({
id,
tabId,
@@ -245,13 +255,13 @@ export async function handleLegacyExternalMethodFormat(
signMessageRequest: payload,
signMessageResponse: 'cancel',
},
- method: ExternalSatsMethods.signMessageResponse,
+ method: SatsConnectMethods.signMessageResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.sendBtcRequest: {
+ case SatsConnectMethods.sendBtcRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['sendBtcRequest', payload],
]);
@@ -266,13 +276,13 @@ export async function handleLegacyExternalMethodFormat(
sendBtcRequest: payload,
sendBtcResponse: 'cancel',
},
- method: ExternalSatsMethods.sendBtcResponse,
+ method: SatsConnectMethods.sendBtcResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.createInscriptionRequest: {
+ case SatsConnectMethods.createInscriptionRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['createInscription', payload],
]);
@@ -287,13 +297,13 @@ export async function handleLegacyExternalMethodFormat(
createInscriptionRequest: payload,
createInscriptionResponse: 'cancel',
},
- method: ExternalSatsMethods.createInscriptionResponse,
+ method: SatsConnectMethods.createInscriptionResponse,
},
});
listenForOriginTabClose({ tabId });
break;
}
- case ExternalSatsMethods.createRepeatInscriptionsRequest: {
+ case SatsConnectMethods.createRepeatInscriptionsRequest: {
const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [
['createRepeatInscriptions', payload],
]);
@@ -311,7 +321,7 @@ export async function handleLegacyExternalMethodFormat(
createRepeatInscriptionsRequest: payload,
createRepeatInscriptionsResponse: 'cancel',
},
- method: ExternalSatsMethods.createRepeatInscriptionsResponse,
+ method: SatsConnectMethods.createRepeatInscriptionsResponse,
},
});
listenForOriginTabClose({ tabId });
diff --git a/src/common/utils/route-urls.ts b/src/common/utils/route-urls.ts
index e2bfaac9a..3615c5aa2 100644
--- a/src/common/utils/route-urls.ts
+++ b/src/common/utils/route-urls.ts
@@ -3,7 +3,10 @@ enum RequestsRoutes {
TransactionRequest = '/transaction-request',
AuthenticationRequest = '/authentication-request',
SignatureRequest = '/signature-request',
+ SignMessageRequest = '/sign-message-request',
AddressRequest = '/btc-select-address-request',
+ StxAddressRequest = '/stx-select-address-request',
+ StxAccountRequest = '/stx-select-account-request',
SignBtcTx = '/psbt-signing-request',
SignBatchBtcTx = '/batch-psbt-signing-request',
SendBtcTx = '/btc-send-request',
diff --git a/src/common/utils/rpc/btc/getAccounts.ts b/src/common/utils/rpc/btc/getAccounts.ts
new file mode 100644
index 000000000..0bec5b8fa
--- /dev/null
+++ b/src/common/utils/rpc/btc/getAccounts.ts
@@ -0,0 +1,65 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort } from '@common/utils';
+import { AddressPurpose, RpcErrorCode } from 'sats-connect';
+import { z } from 'zod';
+import {
+ ParamsKeyValueArray,
+ listenForOriginTabClose,
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../legacy-external-message-handler';
+import RequestsRoutes from '../../route-urls';
+import { makeRPCError, sendRpcResponse } from '../helpers';
+
+const AddressPurposeSchema = z.enum([
+ AddressPurpose.Ordinals,
+ AddressPurpose.Payment,
+ AddressPurpose.Stacks,
+]);
+
+const GetAccountsParamsSchema = z.object({
+ purposes: z.array(AddressPurposeSchema),
+ message: z.string().optional(),
+});
+
+export const handleGetAccounts = async (
+ message: WebBtcMessage<'getAccounts'>,
+ port: chrome.runtime.Port,
+) => {
+ const paramsParseResult = GetAccountsParamsSchema.safeParse(message.params);
+
+ if (!paramsParseResult.success) {
+ const invalidParamsError = makeRPCError(message.id, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: 'Invalid params',
+ });
+ sendRpcResponse(getTabIdFromPort(port), invalidParamsError);
+ return;
+ }
+
+ const requestParams: ParamsKeyValueArray = [
+ ['purposes', paramsParseResult.data.purposes.toString()],
+ ['requestId', message.id as string],
+ ['rpcMethod', message.method],
+ ];
+
+ if (paramsParseResult.data.message) {
+ requestParams.push(['message', paramsParseResult.data.message]);
+ }
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.AddressRequest, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to get accounts',
+ }),
+ });
+ listenForOriginTabClose({ tabId });
+};
+
+export default handleGetAccounts;
diff --git a/src/common/utils/rpc/btc/getAddresses.ts b/src/common/utils/rpc/btc/getAddresses.ts
new file mode 100644
index 000000000..40bd5def0
--- /dev/null
+++ b/src/common/utils/rpc/btc/getAddresses.ts
@@ -0,0 +1,61 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort } from '@common/utils';
+import { AddressPurpose, RpcErrorCode } from 'sats-connect';
+import { z } from 'zod';
+import {
+ ParamsKeyValueArray,
+ listenForOriginTabClose,
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../legacy-external-message-handler';
+import RequestsRoutes from '../../route-urls';
+import { makeRPCError, sendRpcResponse } from '../helpers';
+
+const AddressPurposeSchema = z.enum([AddressPurpose.Ordinals, AddressPurpose.Payment]);
+
+const GetAddressesParamsSchema = z.object({
+ purposes: z.array(AddressPurposeSchema),
+ message: z.string().optional(),
+});
+
+export const handleGetAddresses = async (
+ message: WebBtcMessage<'getAddresses'>,
+ port: chrome.runtime.Port,
+) => {
+ const paramsParseResult = GetAddressesParamsSchema.safeParse(message.params);
+
+ if (!paramsParseResult.success) {
+ const invalidParamsError = makeRPCError(message.id, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: 'Invalid params',
+ });
+ sendRpcResponse(getTabIdFromPort(port), invalidParamsError);
+ return;
+ }
+
+ const requestParams: ParamsKeyValueArray = [
+ ['purposes', message.params.purposes.toString()],
+ ['requestId', message.id as string],
+ ['rpcMethod', message.method],
+ ];
+
+ if (message.params.message) {
+ requestParams.push(['message', message.params.message]);
+ }
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.AddressRequest, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to get addresses',
+ }),
+ });
+ listenForOriginTabClose({ tabId });
+};
+
+export default handleGetAddresses;
diff --git a/src/common/utils/rpc/btc/index.ts b/src/common/utils/rpc/btc/index.ts
new file mode 100644
index 000000000..e2f28e96c
--- /dev/null
+++ b/src/common/utils/rpc/btc/index.ts
@@ -0,0 +1,5 @@
+export * from './getAccounts';
+export * from './getAddresses';
+export * from './sendTransfer';
+export * from './signMessage';
+export * from './signPsbt';
diff --git a/src/common/utils/rpc/btc/sendTransfer.ts b/src/common/utils/rpc/btc/sendTransfer.ts
new file mode 100644
index 000000000..9f6e67d02
--- /dev/null
+++ b/src/common/utils/rpc/btc/sendTransfer.ts
@@ -0,0 +1,57 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort } from '@common/utils';
+import { RpcErrorCode } from 'sats-connect';
+import { z } from 'zod';
+import {
+ ParamsKeyValueArray,
+ listenForOriginTabClose,
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../legacy-external-message-handler';
+import RequestsRoutes from '../../route-urls';
+import { makeRPCError, sendRpcResponse } from '../helpers';
+
+const TransferRecipientSchema = z.object({
+ address: z.string(),
+ amount: z.number(),
+});
+
+const SendTransferParamsSchema = z.object({
+ recipients: z.array(TransferRecipientSchema),
+});
+
+export const handleSendTransfer = async (
+ message: WebBtcMessage<'sendTransfer'>,
+ port: chrome.runtime.Port,
+) => {
+ const paramsParseResult = SendTransferParamsSchema.safeParse(message.params);
+
+ if (!paramsParseResult.success) {
+ const invalidParamsError = makeRPCError(message.id, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: 'Invalid params',
+ });
+ sendRpcResponse(getTabIdFromPort(port), invalidParamsError);
+ return;
+ }
+ const requestParams: ParamsKeyValueArray = [
+ ['recipients', JSON.stringify(paramsParseResult.data.recipients)],
+ ['requestId', message.id as string],
+ ];
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.SendBtcTx, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to send transfer',
+ }),
+ });
+ listenForOriginTabClose({ tabId });
+};
+
+export default handleSendTransfer;
diff --git a/src/common/utils/rpc/btc/signMessage.ts b/src/common/utils/rpc/btc/signMessage.ts
new file mode 100644
index 000000000..969545e75
--- /dev/null
+++ b/src/common/utils/rpc/btc/signMessage.ts
@@ -0,0 +1,53 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { RpcErrorCode } from 'sats-connect';
+import { z } from 'zod';
+import {
+ ParamsKeyValueArray,
+ listenForOriginTabClose,
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../legacy-external-message-handler';
+import RequestsRoutes from '../../route-urls';
+import { makeRPCError } from '../helpers';
+
+const SignMessageSchema = z.object({
+ address: z.string(),
+ message: z.string(),
+});
+
+export const handleSignMessage = async (
+ message: WebBtcMessage<'signMessage'>,
+ port: chrome.runtime.Port,
+) => {
+ const safeParseResult = SignMessageSchema.safeParse(message.params);
+ if (!safeParseResult.success) {
+ const invalidParamsError = makeRPCError(message.id, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: 'Invalid params',
+ });
+ port.postMessage(invalidParamsError);
+ return;
+ }
+
+ const requestParams: ParamsKeyValueArray = [
+ ['address', safeParseResult.data.address],
+ ['message', safeParseResult.data.message],
+ ['requestId', message.id as string],
+ ];
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.SignMessageRequest, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to sign Message',
+ }),
+ });
+ listenForOriginTabClose({ tabId });
+};
+
+export default handleSignMessage;
diff --git a/src/common/utils/rpc/btc/signPsbt.ts b/src/common/utils/rpc/btc/signPsbt.ts
new file mode 100644
index 000000000..3f7d62eb4
--- /dev/null
+++ b/src/common/utils/rpc/btc/signPsbt.ts
@@ -0,0 +1,60 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { RpcErrorCode } from 'sats-connect';
+import { z } from 'zod';
+import {
+ ParamsKeyValueArray,
+ listenForOriginTabClose,
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../legacy-external-message-handler';
+import RequestsRoutes from '../../route-urls';
+import { makeRPCError } from '../helpers';
+
+const SignPsbtSchema = z.object({
+ psbt: z.string(),
+ signInputs: z.record(z.array(z.number())), // Record
+ broadcast: z.boolean().optional(),
+ allowedSignHash: z.number().optional(),
+});
+
+export const handleSignPsbt = async (
+ message: WebBtcMessage<'signPsbt'>,
+ port: chrome.runtime.Port,
+) => {
+ const paramsParseResult = SignPsbtSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ const invalidParamsError = makeRPCError(message.id, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: 'Invalid params',
+ });
+ port.postMessage(invalidParamsError);
+ return;
+ }
+
+ const requestParams: ParamsKeyValueArray = [
+ ['requestId', message.id as string],
+ ['signInputs', JSON.stringify(paramsParseResult.data.signInputs)],
+ ['psbt', paramsParseResult.data.psbt],
+ ];
+
+ if (message.params.broadcast) requestParams.push(['broadcast', String(message.params.broadcast)]);
+ if (message.params.allowedSignHash)
+ requestParams.push(['allowedSigHash', message.params.allowedSignHash.toString()]);
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.SignBtcTx, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to send transfer',
+ }),
+ });
+
+ listenForOriginTabClose({ tabId });
+};
+
+export default handleSignPsbt;
diff --git a/src/common/utils/rpc/getInfo.ts b/src/common/utils/rpc/getInfo.ts
new file mode 100644
index 000000000..f68003c09
--- /dev/null
+++ b/src/common/utils/rpc/getInfo.ts
@@ -0,0 +1,16 @@
+import { Requests, Return, RpcId } from 'sats-connect';
+import { keys } from 'ts-transformer-keys';
+import { makeRpcSuccessResponse, sendRpcResponse } from './helpers';
+
+declare const VERSION: string;
+
+const handleGetInfo = (requestId: RpcId, tabId: number) => {
+ const response: Return<'getInfo'> = {
+ version: VERSION,
+ methods: keys(),
+ supports: [],
+ };
+ sendRpcResponse(tabId, makeRpcSuccessResponse(requestId, response));
+};
+
+export default handleGetInfo;
diff --git a/src/common/utils/rpc/helpers.ts b/src/common/utils/rpc/helpers.ts
new file mode 100644
index 000000000..355f56237
--- /dev/null
+++ b/src/common/utils/rpc/helpers.ts
@@ -0,0 +1,34 @@
+import { MESSAGE_SOURCE } from '@common/types/message-types';
+import {
+ Requests,
+ Return,
+ RpcError,
+ RpcErrorResponse,
+ RpcId,
+ RpcSuccessResponse,
+} from 'sats-connect';
+
+export const makeRPCError = (id: RpcId, error: RpcError): RpcErrorResponse => ({
+ jsonrpc: '2.0',
+ id,
+ error,
+});
+
+export const makeRpcSuccessResponse = (
+ id: RpcId,
+ result: Return,
+): RpcSuccessResponse => ({
+ id,
+ result,
+ jsonrpc: '2.0',
+});
+
+export function sendRpcResponse(
+ tabId: number,
+ response: RpcSuccessResponse | RpcErrorResponse,
+) {
+ chrome.tabs.sendMessage(+tabId, {
+ ...response,
+ source: MESSAGE_SOURCE,
+ });
+}
diff --git a/src/common/utils/rpc/index.ts b/src/common/utils/rpc/index.ts
new file mode 100644
index 000000000..b4b2d8ecc
--- /dev/null
+++ b/src/common/utils/rpc/index.ts
@@ -0,0 +1,106 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { Requests, RpcErrorCode } from 'sats-connect';
+import { getTabIdFromPort } from '..';
+import {
+ handleGetAccounts,
+ handleGetAddresses,
+ handleSendTransfer,
+ handleSignMessage,
+ handleSignPsbt,
+} from './btc';
+import handleGetInfo from './getInfo';
+import { makeRPCError, sendRpcResponse } from './helpers';
+import callContract from './stx/callContract/index.ts';
+import deployContract from './stx/deployContract/index.ts';
+import handleGetStxAccounts from './stx/getAccounts';
+import handleGetStxAddresses from './stx/getAddresses';
+import handleStacksSignMessage from './stx/signMessage';
+import handleStacksSignStructuredMessage from './stx/signStructuredMessage';
+import signTransaction from './stx/signTransaction';
+import transferStx from './stx/transferStx';
+
+const handleRPCRequest = async (
+ message: WebBtcMessage,
+ port: chrome.runtime.Port,
+) => {
+ try {
+ switch (message.method) {
+ case 'getInfo':
+ handleGetInfo(message.id, getTabIdFromPort(port));
+ break;
+ case 'getAddresses':
+ await handleGetAddresses(message as WebBtcMessage<'getAddresses'>, port);
+ break;
+ case 'getAccounts': {
+ await handleGetAccounts(message as WebBtcMessage<'getAccounts'>, port);
+ break;
+ }
+ case 'signMessage':
+ await handleSignMessage(message as WebBtcMessage<'signMessage'>, port);
+ break;
+ case 'sendTransfer':
+ await handleSendTransfer(message as WebBtcMessage<'sendTransfer'>, port);
+ break;
+ case 'signPsbt':
+ await handleSignPsbt(message as WebBtcMessage<'signPsbt'>, port);
+ break;
+
+ // Stacks methods
+
+ case 'stx_callContract': {
+ await callContract(message as WebBtcMessage<'stx_callContract'>, port);
+ break;
+ }
+ case 'stx_deployContract': {
+ await deployContract(message as WebBtcMessage<'stx_deployContract'>, port);
+ break;
+ }
+ case 'stx_getAccounts': {
+ await handleGetStxAccounts(message as WebBtcMessage<'stx_getAccounts'>, port);
+ break;
+ }
+ case 'stx_getAddresses': {
+ await handleGetStxAddresses(message as WebBtcMessage<'stx_getAddresses'>, port);
+ break;
+ }
+ case 'stx_signTransaction': {
+ await signTransaction(message as WebBtcMessage<'stx_signTransaction'>, port);
+ break;
+ }
+ case 'stx_transferStx': {
+ await transferStx(message as WebBtcMessage<'stx_transferStx'>, port);
+ break;
+ }
+ case 'stx_signMessage': {
+ await handleStacksSignMessage(message as WebBtcMessage<'stx_signMessage'>, port);
+ break;
+ }
+ case 'stx_signStructuredMessage': {
+ await handleStacksSignStructuredMessage(
+ message as WebBtcMessage<'stx_signStructuredMessage'>,
+ port,
+ );
+ break;
+ }
+ default:
+ sendRpcResponse(
+ getTabIdFromPort(port),
+ makeRPCError(message.id as string, {
+ code: RpcErrorCode.METHOD_NOT_FOUND,
+ message: `"${message.method}" is not supported.`,
+ }),
+ );
+ break;
+ }
+ } catch (e: any) {
+ sendRpcResponse(
+ getTabIdFromPort(port),
+ makeRPCError(message.id as string, {
+ code: RpcErrorCode.INTERNAL_ERROR,
+ message: e.message,
+ }),
+ );
+ }
+};
+
+export default handleRPCRequest;
diff --git a/src/common/utils/rpc/stx/callContract/index.ts.ts b/src/common/utils/rpc/stx/callContract/index.ts.ts
new file mode 100644
index 000000000..23c1c8d87
--- /dev/null
+++ b/src/common/utils/rpc/stx/callContract/index.ts.ts
@@ -0,0 +1,66 @@
+import { MESSAGE_SOURCE, WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined, stringifyData } from '@common/utils';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '@common/utils/legacy-external-message-handler';
+import RequestsRoutes from '@common/utils/route-urls';
+import { RpcErrorCode } from 'sats-connect';
+import { makeRPCError } from '../../helpers';
+import { sendInvalidParametersMessage, sendMissingParametersMessage } from '../rpcResponseMessages';
+import { callContractParamsSchema } from './paramsSchema';
+
+async function callContract(message: WebBtcMessage<'stx_callContract'>, port: chrome.runtime.Port) {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const paramsParseResult = callContractParamsSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ sendInvalidParametersMessage({
+ tabId: getTabIdFromPort(port),
+ messageId: message.id,
+ error: paramsParseResult.error,
+ });
+ return;
+ }
+
+ // TODO: Checks,
+ //
+ // 1. The contract name is valid
+ // 2. The params are valid Clarity values
+ //
+ // Assuming that the checks performed by the schema for the function name are
+ // good enough for now.
+
+ const popupParams = {
+ // RPC params
+ contract: paramsParseResult.data.contract,
+ functionName: paramsParseResult.data.functionName,
+ arguments: stringifyData(paramsParseResult.data.arguments),
+
+ // Metadata
+ rpcMethod: 'stx_callContract',
+ messageId: message.id,
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, popupParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.TransactionRequest, urlParams);
+
+ listenForPopupClose({
+ tabId,
+ id,
+ response: {
+ ...makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the Stacks transaction signing request',
+ }),
+ source: MESSAGE_SOURCE,
+ },
+ });
+}
+
+export default callContract;
diff --git a/src/common/utils/rpc/stx/callContract/paramsSchema.ts b/src/common/utils/rpc/stx/callContract/paramsSchema.ts
new file mode 100644
index 000000000..aedaa1806
--- /dev/null
+++ b/src/common/utils/rpc/stx/callContract/paramsSchema.ts
@@ -0,0 +1,9 @@
+/* eslint-disable import/prefer-default-export */
+import { CallContractParams } from 'sats-connect';
+import { z } from 'zod';
+
+export const callContractParamsSchema = z.object({
+ contract: z.string(),
+ functionName: z.string(),
+ arguments: z.array(z.string()).optional(),
+}) satisfies z.ZodSchema;
diff --git a/src/common/utils/rpc/stx/deployContract/index.ts.ts b/src/common/utils/rpc/stx/deployContract/index.ts.ts
new file mode 100644
index 000000000..0ba64cfd1
--- /dev/null
+++ b/src/common/utils/rpc/stx/deployContract/index.ts.ts
@@ -0,0 +1,61 @@
+import { MESSAGE_SOURCE, WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined, stringifyData } from '@common/utils';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '@common/utils/legacy-external-message-handler';
+import RequestsRoutes from '@common/utils/route-urls';
+import { RpcErrorCode } from 'sats-connect';
+import { makeRPCError } from '../../helpers';
+import { sendInvalidParametersMessage, sendMissingParametersMessage } from '../rpcResponseMessages';
+import { deployContractParamsSchema } from './paramsSchema';
+
+async function deployContract(
+ message: WebBtcMessage<'stx_deployContract'>,
+ port: chrome.runtime.Port,
+) {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const paramsParseResult = deployContractParamsSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ sendInvalidParametersMessage({
+ tabId: getTabIdFromPort(port),
+ messageId: message.id,
+ error: paramsParseResult.error,
+ });
+ return;
+ }
+
+ const popupParams = {
+ // RPC params
+ name: paramsParseResult.data.name,
+ clarityCode: stringifyData(paramsParseResult.data.clarityCode),
+ clarityVersion: paramsParseResult.data.clarityVersion,
+
+ // Metadata
+ rpcMethod: 'stx_deployContract',
+ messageId: message.id,
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, popupParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.TransactionRequest, urlParams);
+
+ listenForPopupClose({
+ tabId,
+ id,
+ response: {
+ ...makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the Stacks transaction signing request',
+ }),
+ source: MESSAGE_SOURCE,
+ },
+ });
+}
+
+export default deployContract;
diff --git a/src/common/utils/rpc/stx/deployContract/paramsSchema.ts b/src/common/utils/rpc/stx/deployContract/paramsSchema.ts
new file mode 100644
index 000000000..4ce56e714
--- /dev/null
+++ b/src/common/utils/rpc/stx/deployContract/paramsSchema.ts
@@ -0,0 +1,9 @@
+/* eslint-disable import/prefer-default-export */
+import { DeployContractParams } from 'sats-connect';
+import { z } from 'zod';
+
+export const deployContractParamsSchema = z.object({
+ name: z.string(),
+ clarityCode: z.string(),
+ clarityVersion: z.string().optional(),
+}) satisfies z.ZodSchema;
diff --git a/src/common/utils/rpc/stx/getAccounts/index.ts b/src/common/utils/rpc/stx/getAccounts/index.ts
new file mode 100644
index 000000000..f637d9d74
--- /dev/null
+++ b/src/common/utils/rpc/stx/getAccounts/index.ts
@@ -0,0 +1,40 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined } from '@common/utils';
+import { RpcErrorCode } from 'sats-connect';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../../legacy-external-message-handler';
+import RequestsRoutes from '../../../route-urls';
+import { makeRPCError } from '../../helpers';
+import { sendMissingParametersMessage } from '../rpcResponseMessages';
+
+const handleGetStxAccounts = async (
+ message: WebBtcMessage<'stx_getAccounts'>,
+ port: chrome.runtime.Port,
+) => {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const popupParams = {
+ messageId: message.id,
+ rpcMethod: 'stx_getAccounts',
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, popupParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.StxAccountRequest, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to get accounts',
+ }),
+ });
+};
+
+export default handleGetStxAccounts;
diff --git a/src/common/utils/rpc/stx/getAddresses/index.ts b/src/common/utils/rpc/stx/getAddresses/index.ts
new file mode 100644
index 000000000..1771f492d
--- /dev/null
+++ b/src/common/utils/rpc/stx/getAddresses/index.ts
@@ -0,0 +1,33 @@
+import { WebBtcMessage } from '@common/types/message-types';
+import { RpcErrorCode } from 'sats-connect';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '../../../legacy-external-message-handler';
+import RequestsRoutes from '../../../route-urls';
+import { makeRPCError } from '../../helpers';
+
+const handleGetStxAddresses = async (
+ message: WebBtcMessage<'stx_getAddresses'>,
+ port: chrome.runtime.Port,
+) => {
+ const popupParams = {
+ messageId: message.id,
+ rpcMethod: 'stx_getAddresses',
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, popupParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.StxAddressRequest, urlParams);
+ listenForPopupClose({
+ tabId,
+ id,
+ response: makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected request to get addresses',
+ }),
+ });
+};
+
+export default handleGetStxAddresses;
diff --git a/src/common/utils/rpc/stx/rpcResponseMessages.ts b/src/common/utils/rpc/stx/rpcResponseMessages.ts
new file mode 100644
index 000000000..fa7b5a0b5
--- /dev/null
+++ b/src/common/utils/rpc/stx/rpcResponseMessages.ts
@@ -0,0 +1,163 @@
+/* eslint-disable import/prefer-default-export */
+import { Return, RpcErrorCode, RpcId } from 'sats-connect';
+import { ZodError } from 'zod';
+import { makeRPCError, makeRpcSuccessResponse, sendRpcResponse } from '../helpers';
+
+type BaseArgs = {
+ tabId: NonNullable;
+ messageId: RpcId;
+};
+
+export function sendMissingParametersMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INVALID_REQUEST,
+ message: 'Missing parameters.',
+ }),
+ );
+}
+
+type InvalidParametersMessageArgs = BaseArgs & {
+ error: ZodError;
+};
+export function sendInvalidParametersMessage({
+ tabId,
+ messageId,
+ error,
+}: InvalidParametersMessageArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: `Invalid parameters. ${error.toString()}`,
+ }),
+ );
+}
+
+export function sendInvalidStacksTransactionMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INVALID_PARAMS,
+ message: 'Invalid Stacks transaction.',
+ }),
+ );
+}
+
+export function sendNetworkMismatchMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INVALID_REQUEST,
+ message: 'Network mismatch.',
+ }),
+ );
+}
+
+export function sendAddressMismatchMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INVALID_REQUEST,
+ message: 'Address mismatch.',
+ }),
+ );
+}
+
+export function sendInternalErrorMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INTERNAL_ERROR,
+ message: 'Internal error.',
+ }),
+ );
+}
+
+export function sendUserRejectionMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the request.',
+ }),
+ );
+}
+
+export function sendMissingFunctionArgumentsMessage({ tabId, messageId }: BaseArgs) {
+ sendRpcResponse(
+ tabId,
+ makeRPCError(messageId, {
+ code: RpcErrorCode.INVALID_REQUEST,
+ message: 'Missing function arguments.',
+ }),
+ );
+}
+
+type SignTransactionSuccessArgs = BaseArgs & {
+ result: Return<'stx_signTransaction'>;
+};
+export function sendSignTransactionSuccessResponseMessage({
+ tabId,
+ messageId,
+ result,
+}: SignTransactionSuccessArgs) {
+ sendRpcResponse(tabId, makeRpcSuccessResponse(messageId, result));
+}
+
+type CallContractSuccessArgs = BaseArgs & {
+ result: Return<'stx_callContract'>;
+};
+export function sendCallContractSuccessResponseMessage({
+ tabId,
+ messageId,
+ result,
+}: CallContractSuccessArgs) {
+ sendRpcResponse(tabId, makeRpcSuccessResponse(messageId, result));
+}
+
+type StxTransferSuccessArgs = BaseArgs & {
+ result: Return<'stx_transferStx'>;
+};
+export function sendStxTransferSuccessResponseMessage({
+ tabId,
+ messageId,
+ result,
+}: StxTransferSuccessArgs) {
+ sendRpcResponse(tabId, makeRpcSuccessResponse(messageId, result));
+}
+
+type GetAccountsSuccess = BaseArgs & {
+ result: Return<'stx_getAccounts'>;
+};
+export function sendGetAccountsSuccessResponseMessage({
+ tabId,
+ messageId,
+ result,
+}: GetAccountsSuccess) {
+ sendRpcResponse(tabId, makeRpcSuccessResponse(messageId, result));
+}
+
+type GetAddressesSuccess = BaseArgs & {
+ result: Return<'stx_getAddresses'>;
+};
+export function sendGetAddressesSuccessResponseMessage({
+ tabId,
+ messageId,
+ result,
+}: GetAddressesSuccess) {
+ sendRpcResponse(tabId, makeRpcSuccessResponse(messageId, result));
+}
+
+// Same as above, but for `stx_deployContract
+type DeployContractSuccessArgs = BaseArgs & {
+ result: Return<'stx_deployContract'>;
+};
+export function sendDeployContractSuccessResponseMessage({
+ tabId,
+ messageId,
+ result,
+}: DeployContractSuccessArgs) {
+ sendRpcResponse(tabId, makeRpcSuccessResponse(messageId, result));
+}
diff --git a/src/common/utils/rpc/stx/signMessage/index.ts b/src/common/utils/rpc/stx/signMessage/index.ts
new file mode 100644
index 000000000..4c70b73c0
--- /dev/null
+++ b/src/common/utils/rpc/stx/signMessage/index.ts
@@ -0,0 +1,56 @@
+import { MESSAGE_SOURCE, WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined } from '@common/utils';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '@common/utils/legacy-external-message-handler';
+import RequestsRoutes from '@common/utils/route-urls';
+import { RpcErrorCode } from 'sats-connect';
+import { makeRPCError } from '../../helpers';
+import { sendInvalidParametersMessage, sendMissingParametersMessage } from '../rpcResponseMessages';
+import { rpcParamsSchema } from './paramsSchema';
+
+async function handleStacksSignMessage(
+ message: WebBtcMessage<'stx_signMessage'>,
+ port: chrome.runtime.Port,
+) {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const paramsParseResult = rpcParamsSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ sendInvalidParametersMessage({
+ tabId: getTabIdFromPort(port),
+ messageId: message.id,
+ error: paramsParseResult.error,
+ });
+ return;
+ }
+
+ const requestParams = {
+ message: message.params.message,
+ messageId: String(message.id),
+ rpcMethod: message.method,
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.SignatureRequest, urlParams);
+
+ listenForPopupClose({
+ tabId,
+ id,
+ response: {
+ ...makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the message signing request',
+ }),
+ source: MESSAGE_SOURCE,
+ },
+ });
+}
+
+export default handleStacksSignMessage;
diff --git a/src/common/utils/rpc/stx/signMessage/paramsSchema.ts b/src/common/utils/rpc/stx/signMessage/paramsSchema.ts
new file mode 100644
index 000000000..09efa2c5d
--- /dev/null
+++ b/src/common/utils/rpc/stx/signMessage/paramsSchema.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod';
+
+export const rpcParamsSchema = z.object({
+ message: z.string(),
+});
+
+export type Params = z.infer;
diff --git a/src/common/utils/rpc/stx/signStructuredMessage/index.ts b/src/common/utils/rpc/stx/signStructuredMessage/index.ts
new file mode 100644
index 000000000..24c97ef32
--- /dev/null
+++ b/src/common/utils/rpc/stx/signStructuredMessage/index.ts
@@ -0,0 +1,64 @@
+import { MESSAGE_SOURCE, WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined } from '@common/utils';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '@common/utils/legacy-external-message-handler';
+import RequestsRoutes from '@common/utils/route-urls';
+import { RpcErrorCode } from 'sats-connect';
+import { z } from 'zod';
+import { makeRPCError } from '../../helpers';
+import { sendInvalidParametersMessage, sendMissingParametersMessage } from '../rpcResponseMessages';
+
+export const rpcParamsSchema = z.object({
+ message: z.string(),
+ domain: z.string(),
+});
+
+export type Params = z.infer;
+
+async function handleStacksSignStructuredMessage(
+ message: WebBtcMessage<'stx_signStructuredMessage'>,
+ port: chrome.runtime.Port,
+) {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const paramsParseResult = rpcParamsSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ sendInvalidParametersMessage({
+ tabId: getTabIdFromPort(port),
+ messageId: message.id,
+ error: paramsParseResult.error,
+ });
+ return;
+ }
+
+ const requestParams = {
+ message: message.params.message,
+ domain: message.params.domain,
+ messageId: String(message.id),
+ rpcMethod: message.method,
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.SignatureRequest, urlParams);
+
+ listenForPopupClose({
+ tabId,
+ id,
+ response: {
+ ...makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the structured message signing request',
+ }),
+ source: MESSAGE_SOURCE,
+ },
+ });
+}
+
+export default handleStacksSignStructuredMessage;
diff --git a/src/common/utils/rpc/stx/signTransaction/index.ts b/src/common/utils/rpc/stx/signTransaction/index.ts
new file mode 100644
index 000000000..d44ac7194
--- /dev/null
+++ b/src/common/utils/rpc/stx/signTransaction/index.ts
@@ -0,0 +1,66 @@
+import { MESSAGE_SOURCE, WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined } from '@common/utils';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '@common/utils/legacy-external-message-handler';
+import RequestsRoutes from '@common/utils/route-urls';
+import { RpcErrorCode } from 'sats-connect';
+import { makeRPCError } from '../../helpers';
+import {
+ sendInvalidParametersMessage,
+ sendInvalidStacksTransactionMessage,
+ sendMissingParametersMessage,
+} from '../rpcResponseMessages';
+import paramsSchema from './paramsSchema';
+import { isValidStacksTransaction } from './utils';
+
+async function signTransaction(
+ message: WebBtcMessage<'stx_signTransaction'>,
+ port: chrome.runtime.Port,
+) {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const paramsParseResult = paramsSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ sendInvalidParametersMessage({
+ tabId: getTabIdFromPort(port),
+ messageId: message.id,
+ error: paramsParseResult.error,
+ });
+ return;
+ }
+
+ if (!isValidStacksTransaction(message.params.transaction)) {
+ sendInvalidStacksTransactionMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const popupParams = {
+ transaction: message.params.transaction,
+ messageId: String(message.id),
+ rpcMethod: message.method,
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, popupParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.TransactionRequest, urlParams);
+
+ listenForPopupClose({
+ tabId,
+ id,
+ response: {
+ ...makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the Stacks transaction signing request',
+ }),
+ source: MESSAGE_SOURCE,
+ },
+ });
+}
+
+export default signTransaction;
diff --git a/src/common/utils/rpc/stx/signTransaction/paramsSchema.ts b/src/common/utils/rpc/stx/signTransaction/paramsSchema.ts
new file mode 100644
index 000000000..c8655f6c5
--- /dev/null
+++ b/src/common/utils/rpc/stx/signTransaction/paramsSchema.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod';
+
+const paramsSchema = z.object({
+ transaction: z.string(),
+ pubkey: z.string().optional(),
+});
+
+export type ParamsSchema = z.infer;
+
+export default paramsSchema;
diff --git a/src/common/utils/rpc/stx/signTransaction/utils.ts b/src/common/utils/rpc/stx/signTransaction/utils.ts
new file mode 100644
index 000000000..d0c1c8c2b
--- /dev/null
+++ b/src/common/utils/rpc/stx/signTransaction/utils.ts
@@ -0,0 +1,11 @@
+import { deserializeTransaction } from '@stacks/transactions';
+
+/* eslint-disable import/prefer-default-export */
+export function isValidStacksTransaction(txHex: string) {
+ try {
+ deserializeTransaction(txHex);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/src/common/utils/rpc/stx/transferStx/index.ts b/src/common/utils/rpc/stx/transferStx/index.ts
new file mode 100644
index 000000000..336bc6129
--- /dev/null
+++ b/src/common/utils/rpc/stx/transferStx/index.ts
@@ -0,0 +1,55 @@
+import { MESSAGE_SOURCE, WebBtcMessage } from '@common/types/message-types';
+import { getTabIdFromPort, isUndefined } from '@common/utils';
+import {
+ listenForPopupClose,
+ makeSearchParamsWithDefaults,
+ triggerRequestWindowOpen,
+} from '@common/utils/legacy-external-message-handler';
+import RequestsRoutes from '@common/utils/route-urls';
+import { RpcErrorCode } from 'sats-connect';
+import { makeRPCError } from '../../helpers';
+import { sendInvalidParametersMessage, sendMissingParametersMessage } from '../rpcResponseMessages';
+import paramsSchema from './paramsSchema';
+
+async function transferStx(message: WebBtcMessage<'stx_transferStx'>, port: chrome.runtime.Port) {
+ if (isUndefined(message.params)) {
+ sendMissingParametersMessage({ tabId: getTabIdFromPort(port), messageId: message.id });
+ return;
+ }
+
+ const paramsParseResult = paramsSchema.safeParse(message.params);
+ if (!paramsParseResult.success) {
+ sendInvalidParametersMessage({
+ tabId: getTabIdFromPort(port),
+ messageId: message.id,
+ error: paramsParseResult.error,
+ });
+ return;
+ }
+
+ const requestParams = {
+ amount: message.params.amount.toString(),
+ recipient: message.params.recipient,
+ memo: message.params.memo,
+ rpcMethod: message.method,
+ messageId: String(message.id),
+ };
+
+ const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams);
+
+ const { id } = await triggerRequestWindowOpen(RequestsRoutes.TransactionRequest, urlParams);
+
+ listenForPopupClose({
+ tabId,
+ id,
+ response: {
+ ...makeRPCError(message.id, {
+ code: RpcErrorCode.USER_REJECTION,
+ message: 'User rejected the Stacks transaction signing request',
+ }),
+ source: MESSAGE_SOURCE,
+ },
+ });
+}
+
+export default transferStx;
diff --git a/src/common/utils/rpc/stx/transferStx/paramsSchema.ts b/src/common/utils/rpc/stx/transferStx/paramsSchema.ts
new file mode 100644
index 000000000..47a7fed6d
--- /dev/null
+++ b/src/common/utils/rpc/stx/transferStx/paramsSchema.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod';
+
+const paramsSchema = z.object({
+ amount: z.union([z.string(), z.number()]),
+ recipient: z.string(),
+ memo: z.string().optional(),
+});
+
+export type ParamsSchema = z.infer;
+
+export default paramsSchema;
diff --git a/src/content-scripts/content-script.ts b/src/content-scripts/content-script.ts
index d14639542..03d334768 100644
--- a/src/content-scripts/content-script.ts
+++ b/src/content-scripts/content-script.ts
@@ -13,12 +13,12 @@ import {
} from '@common/types/inpage-types';
import {
CONTENT_SCRIPT_PORT,
- ExternalMethods,
- ExternalSatsMethods,
LegacyMessageFromContentScript,
LegacyMessageToContentScript,
MESSAGE_SOURCE,
SatsConnectMessageFromContentScript,
+ SatsConnectMethods,
+ StacksLegacyMethods,
} from '@common/types/message-types';
import getEventSourceWindow from '@common/utils/get-event-source-window';
import RequestsRoutes from '@common/utils/route-urls';
@@ -46,7 +46,8 @@ window.addEventListener('message', (event) => {
// Connection to background script - fires onConnect event in background script
// and establishes two-way communication
-let backgroundPort;
+let backgroundPort: chrome.runtime.Port;
+
function connect() {
backgroundPort = chrome.runtime.connect({ name: CONTENT_SCRIPT_PORT });
backgroundPort.onDisconnect.addListener(connect);
@@ -92,7 +93,7 @@ document.addEventListener(DomEventName.authenticationRequest, ((
path: RequestsRoutes.AuthenticationRequest,
payload: event.detail.authenticationRequest,
urlParam: 'authRequest',
- method: ExternalMethods.authenticationRequest,
+ method: StacksLegacyMethods.authenticationRequest,
});
}) as EventListener);
@@ -102,7 +103,7 @@ document.addEventListener(DomEventName.transactionRequest, ((event: TransactionR
path: RequestsRoutes.TransactionRequest,
payload: event.detail.transactionRequest,
urlParam: 'request',
- method: ExternalMethods.transactionRequest,
+ method: StacksLegacyMethods.transactionRequest,
});
}) as EventListener);
@@ -112,7 +113,7 @@ document.addEventListener(DomEventName.signatureRequest, ((event: SignatureReque
path: RequestsRoutes.SignatureRequest,
payload: event.detail.signatureRequest,
urlParam: 'request',
- method: ExternalMethods.signatureRequest,
+ method: StacksLegacyMethods.signatureRequest,
});
}) as EventListener);
@@ -124,7 +125,7 @@ document.addEventListener(DomEventName.structuredDataSignatureRequest, ((
path: RequestsRoutes.SignatureRequest,
payload: event.detail.signatureRequest,
urlParam: 'request',
- method: ExternalMethods.structuredDataSignatureRequest,
+ method: StacksLegacyMethods.structuredDataSignatureRequest,
});
}) as EventListener);
@@ -134,7 +135,7 @@ document.addEventListener(DomEventName.getAddressRequest, ((event: GetAddressReq
path: RequestsRoutes.AddressRequest,
payload: event.detail.btcAddressRequest,
urlParam: 'addressRequest',
- method: ExternalSatsMethods.getAddressRequest,
+ method: SatsConnectMethods.getAddressRequest,
});
}) as EventListener);
@@ -144,7 +145,7 @@ document.addEventListener(DomEventName.signPsbtRequest, ((event: SignPsbtRequest
path: RequestsRoutes.SignBtcTx,
payload: event.detail.signPsbtRequest,
urlParam: 'signPsbtRequest',
- method: ExternalSatsMethods.signPsbtRequest,
+ method: SatsConnectMethods.signPsbtRequest,
});
}) as EventListener);
@@ -156,17 +157,17 @@ document.addEventListener(DomEventName.signBatchPsbtRequest, ((
path: RequestsRoutes.SignBatchBtcTx,
payload: event.detail.signBatchPsbtRequest,
urlParam: 'signBatchPsbtRequest',
- method: ExternalSatsMethods.signBatchPsbtRequest,
+ method: SatsConnectMethods.signBatchPsbtRequest,
});
}) as EventListener);
// Listen for a CustomEvent (Message Signing request) coming from the web app
document.addEventListener(DomEventName.signMessageRequest, ((event: SignMessageRequestEvent) => {
forwardDomEventToBackground({
- path: RequestsRoutes.SignatureRequest,
+ path: RequestsRoutes.SignMessageRequest,
payload: event.detail.signMessageRequest,
urlParam: 'signMessageRequest',
- method: ExternalSatsMethods.signMessageRequest,
+ method: SatsConnectMethods.signMessageRequest,
});
}) as EventListener);
@@ -176,7 +177,7 @@ document.addEventListener(DomEventName.sendBtcRequest, ((event: SendBtcRequestEv
path: RequestsRoutes.SendBtcTx,
payload: event.detail.sendBtcRequest,
urlParam: 'sendBtcRequest',
- method: ExternalSatsMethods.sendBtcRequest,
+ method: SatsConnectMethods.sendBtcRequest,
});
}) as EventListener);
@@ -188,7 +189,7 @@ document.addEventListener(DomEventName.createInscriptionRequest, ((
path: RequestsRoutes.CreateInscription,
payload: event.detail.createInscriptionRequest,
urlParam: 'createInscriptionRequest',
- method: ExternalSatsMethods.createInscriptionRequest,
+ method: SatsConnectMethods.createInscriptionRequest,
});
}) as EventListener);
@@ -200,10 +201,14 @@ document.addEventListener(DomEventName.createRepeatInscriptionsRequest, ((
path: RequestsRoutes.CreateRepeatInscriptions,
payload: event.detail.createRepeatInscriptionsRequest,
urlParam: 'createRepeatInscriptionsRequest',
- method: ExternalSatsMethods.createRepeatInscriptionsRequest,
+ method: SatsConnectMethods.createRepeatInscriptionsRequest,
});
}) as EventListener);
+document.addEventListener(DomEventName.rpcRequest, (event: any) => {
+ sendMessageToBackground({ source: MESSAGE_SOURCE, ...event.detail });
+});
+
// Inject in-page script (Stacks and Bitcoin Providers)
const injectInPageScript = (isPriority) => {
const inpage = document.createElement('script');
diff --git a/src/inpage/index.ts b/src/inpage/index.ts
index 0f4b453e9..36482678d 100644
--- a/src/inpage/index.ts
+++ b/src/inpage/index.ts
@@ -1,6 +1,7 @@
import { StacksProvider } from '@stacks/connect';
import { BitcoinProvider } from 'sats-connect';
+import { XverseProviderInfo } from '@utils/constants';
import SatsMethodsProvider from './sats.inpage';
import StacksMethodsProvider from './stacks.inpage';
@@ -48,3 +49,7 @@ try {
);
console.error(e);
}
+
+if (!window.btc_providers) window.btc_providers = [];
+
+window.btc_providers.push(XverseProviderInfo);
diff --git a/src/inpage/sats.inpage.ts b/src/inpage/sats.inpage.ts
index 62403ed9f..ef39f2830 100644
--- a/src/inpage/sats.inpage.ts
+++ b/src/inpage/sats.inpage.ts
@@ -11,30 +11,27 @@ import {
import {
CreateInscriptionResponseMessage,
CreateRepeatInscriptionsResponseMessage,
- ExternalSatsMethods,
GetAddressResponseMessage,
- MESSAGE_SOURCE,
- SatsConnectMessageToContentScript,
+ SatsConnectMethods,
SendBtcResponseMessage,
SignBatchPsbtResponseMessage,
SignMessageResponseMessage,
SignPsbtResponseMessage,
} from '@common/types/message-types';
+import { nanoid } from 'nanoid';
import {
BitcoinProvider,
CreateInscriptionResponse,
CreateRepeatInscriptionsResponse,
GetAddressResponse,
+ Params,
+ Requests,
+ RpcRequest,
+ RpcResponse,
SignMultipleTransactionsResponse,
SignTransactionResponse,
} from 'sats-connect';
-
-const isValidEvent = (event: MessageEvent, method: SatsConnectMessageToContentScript['method']) => {
- const { data } = event;
- const correctSource = data.source === MESSAGE_SOURCE;
- const correctMethod = data.method === method;
- return correctSource && correctMethod && !!data.payload;
-};
+import { isValidLegacyEvent, isValidRpcEvent } from './utils';
const SatsMethodsProvider: BitcoinProvider = {
connect: async (btcAddressRequest): Promise => {
@@ -44,7 +41,7 @@ const SatsMethodsProvider: BitcoinProvider = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.getAddressResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.getAddressResponse)) return;
if (eventMessage.data.payload?.addressRequest !== btcAddressRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.addressResponse === 'cancel') {
@@ -65,7 +62,7 @@ const SatsMethodsProvider: BitcoinProvider = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.signPsbtResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.signPsbtResponse)) return;
if (eventMessage.data.payload?.signPsbtRequest !== signPsbtRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.signPsbtResponse === 'cancel') {
@@ -91,7 +88,7 @@ const SatsMethodsProvider: BitcoinProvider = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.signBatchPsbtResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.signBatchPsbtResponse)) return;
if (eventMessage.data.payload?.signBatchPsbtRequest !== signBatchPsbtRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.signBatchPsbtResponse === 'cancel') {
@@ -112,7 +109,7 @@ const SatsMethodsProvider: BitcoinProvider = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.signMessageResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.signMessageResponse)) return;
if (eventMessage.data.payload?.signMessageRequest !== signMessageRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.signMessageResponse === 'cancel') {
@@ -133,7 +130,7 @@ const SatsMethodsProvider: BitcoinProvider = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.sendBtcResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.sendBtcResponse)) return;
if (eventMessage.data.payload?.sendBtcRequest !== sendBtcRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.sendBtcResponse === 'cancel') {
@@ -159,7 +156,7 @@ const SatsMethodsProvider: BitcoinProvider = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.createInscriptionResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.createInscriptionResponse)) return;
if (eventMessage.data.payload?.createInscriptionRequest !== createInscriptionRequest)
return;
window.removeEventListener('message', handleMessage);
@@ -188,7 +185,7 @@ const SatsMethodsProvider: BitcoinProvider = {
const handleMessage = (
eventMessage: MessageEvent,
) => {
- if (!isValidEvent(eventMessage, ExternalSatsMethods.createRepeatInscriptionsResponse))
+ if (!isValidLegacyEvent(eventMessage, SatsConnectMethods.createRepeatInscriptionsResponse))
return;
if (
eventMessage.data.payload?.createRepeatInscriptionsRequest !==
@@ -207,8 +204,31 @@ const SatsMethodsProvider: BitcoinProvider = {
window.addEventListener('message', handleMessage);
});
},
- call(request: string): Promise> {
- throw new Error('`call` function is not implemented');
+ request: async (
+ method: Method,
+ params: Params,
+ ): Promise> => {
+ const id = nanoid();
+ const rpcRequest: RpcRequest> = {
+ jsonrpc: '2.0',
+ id,
+ method,
+ params,
+ };
+ const rpcRequestEvent = new CustomEvent(DomEventName.rpcRequest, { detail: rpcRequest });
+ document.dispatchEvent(rpcRequestEvent);
+ return new Promise((resolve) => {
+ function handleRpcResponseEvent(eventMessage: MessageEvent) {
+ if (!isValidRpcEvent(eventMessage)) return;
+ const response = eventMessage.data;
+ if (response.id !== id) {
+ return;
+ }
+ window.removeEventListener('message', handleRpcResponseEvent);
+ return resolve(response);
+ }
+ window.addEventListener('message', handleRpcResponseEvent);
+ });
},
};
export default SatsMethodsProvider;
diff --git a/src/inpage/stacks.inpage.ts b/src/inpage/stacks.inpage.ts
index 631b9a9fa..622eb843b 100644
--- a/src/inpage/stacks.inpage.ts
+++ b/src/inpage/stacks.inpage.ts
@@ -6,59 +6,14 @@ import {
} from '@common/types/inpage-types';
import {
AuthenticationResponseMessage,
- ExternalMethods,
- LegacyMessageToContentScript,
- MESSAGE_SOURCE,
SignatureResponseMessage,
+ StacksLegacyMethods,
TransactionResponseMessage,
} from '@common/types/message-types';
import { StacksProvider } from '@stacks/connect';
+import { callAndReceive, isValidLegacyEvent } from './utils';
declare const VERSION: string;
-type CallableMethods = keyof typeof ExternalMethods;
-
-interface ExtensionResponse {
- source: 'xverse-extension';
- method: CallableMethods;
-
- [key: string]: any;
-}
-
-const callAndReceive = async (
- methodName: CallableMethods | 'getURL',
- opts: any = {},
-): Promise =>
- new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('Unable to get response from xverse extension'));
- }, 1000);
- const waitForResponse = (event: MessageEvent) => {
- if (
- event.data.source === 'xverse-extension' &&
- event.data.method === `${methodName}Response`
- ) {
- clearTimeout(timeout);
- window.removeEventListener('message', waitForResponse);
- resolve(event.data);
- }
- };
- window.addEventListener('message', waitForResponse);
- window.postMessage(
- {
- method: methodName,
- source: 'xverse-app',
- ...opts,
- },
- window.location.origin,
- );
- });
-
-const isValidEvent = (event: MessageEvent, method: LegacyMessageToContentScript['method']) => {
- const { data } = event;
- const correctSource = data.source === MESSAGE_SOURCE;
- const correctMethod = data.method === method;
- return correctSource && correctMethod && !!data.payload;
-};
const StacksMethodsProvider: Partial = {
getURL: async () => {
@@ -75,7 +30,7 @@ const StacksMethodsProvider: Partial = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalMethods.signatureResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, StacksLegacyMethods.signatureResponse)) return;
if (eventMessage.data.payload?.signatureRequest !== signatureRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.signatureResponse === 'cancel') {
@@ -96,7 +51,7 @@ const StacksMethodsProvider: Partial = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalMethods.signatureResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, StacksLegacyMethods.signatureResponse)) return;
if (eventMessage.data.payload?.signatureRequest !== signatureRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.signatureResponse === 'cancel') {
@@ -120,7 +75,7 @@ const StacksMethodsProvider: Partial = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalMethods.authenticationResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, StacksLegacyMethods.authenticationResponse)) return;
if (eventMessage.data.payload?.authenticationRequest !== authenticationRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.authenticationResponse === 'cancel') {
@@ -139,7 +94,7 @@ const StacksMethodsProvider: Partial = {
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (eventMessage: MessageEvent) => {
- if (!isValidEvent(eventMessage, ExternalMethods.transactionResponse)) return;
+ if (!isValidLegacyEvent(eventMessage, StacksLegacyMethods.transactionResponse)) return;
if (eventMessage.data.payload?.transactionRequest !== transactionRequest) return;
window.removeEventListener('message', handleMessage);
if (eventMessage.data.payload.transactionResponse === 'cancel') {
diff --git a/src/inpage/utils.ts b/src/inpage/utils.ts
new file mode 100644
index 000000000..1d48069ea
--- /dev/null
+++ b/src/inpage/utils.ts
@@ -0,0 +1,59 @@
+import {
+ LegacyMessageToContentScript,
+ MESSAGE_SOURCE,
+ SatsConnectMessageToContentScript,
+ StacksLegacyMethods,
+} from '@common/types/message-types';
+
+type CallableMethods = keyof typeof StacksLegacyMethods;
+
+interface ExtensionResponse {
+ source: 'xverse-extension';
+ method: CallableMethods;
+
+ [key: string]: any;
+}
+
+export const isValidLegacyEvent = (
+ event: MessageEvent,
+ method: SatsConnectMessageToContentScript['method'] | LegacyMessageToContentScript['method'],
+) => {
+ const { data } = event;
+ const correctSource = data.source === MESSAGE_SOURCE;
+ const correctMethod = data.method === method;
+ return correctSource && correctMethod && !!data.payload;
+};
+
+export const isValidRpcEvent = (event: MessageEvent) => {
+ const { data } = event;
+ return data.source === MESSAGE_SOURCE;
+};
+
+export const callAndReceive = async (
+ methodName: CallableMethods | 'getURL',
+ opts: any = {},
+): Promise =>
+ new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('Unable to get response from xverse extension'));
+ }, 1000);
+ const waitForResponse = (event: MessageEvent) => {
+ if (
+ event.data.source === 'xverse-extension' &&
+ event.data.method === `${methodName}Response`
+ ) {
+ clearTimeout(timeout);
+ window.removeEventListener('message', waitForResponse);
+ resolve(event.data);
+ }
+ };
+ window.addEventListener('message', waitForResponse);
+ window.postMessage(
+ {
+ method: methodName,
+ source: 'xverse-app',
+ ...opts,
+ },
+ window.location.origin,
+ );
+ });
diff --git a/src/locales/en.json b/src/locales/en.json
index ddfbb56b1..c8365da93 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -19,6 +19,7 @@
"SATS_PER_VB": "sats/vByte"
},
"LANDING_SCREEN": {
+ "BETA": "Beta",
"SCREEN_TITLE": "The Bitcoin wallet for everyone",
"CREATE_WALLET_BUTTON": "Create a new wallet",
"RESTORE_WALLET_BUTTON": "Restore an existing wallet",
@@ -299,7 +300,8 @@
"COPYRIGHT": "© {{year}} Secret Key Labs Limited",
"INSUFFICIENT_FUNDS": "Insufficient funds",
"RATE_TOO_LOW": "Set fee is below minimum of {{minFee}} Sats/vByte",
- "RATE_TOO_HIGH": "Set fee is above maximum of {{maxFee}} Sats/vByte"
+ "RATE_TOO_HIGH": "Set fee is above maximum of {{maxFee}} Sats/vByte",
+ "SPEND_DELEGATED_STX": "You are transferring some STX that is registered for the next stacking cycle. The amount transferred will be subtracted from your current stacking pool delegation, and will not be locked to generate rewards in the next cycle."
},
"CONFIRM_TRANSACTION": {
"SEND": "Confirm Transaction",
@@ -329,6 +331,7 @@
"REVIEW_TRANSACTION": "Review transaction",
"SIGN_TRANSACTIONS": "Sign {{count}} transactions",
"AMOUNT": "Amount",
+ "INSUFFICIENT_BALANCE": "Insufficient balance",
"YOUR_ADDRESS": "Your address",
"FROM": "From",
"INPUT": "Inputs",
@@ -337,6 +340,8 @@
"RECIPIENT": "Recipient",
"ASSET": "Asset",
"CLOSE": "Close",
+ "NAME": "Name",
+ "SYMBOL": "Symbol",
"SIGNING_TRANSACTIONS": "Signing transactions",
"BROADCASTING_TRANSACTIONS": "Broadcasting transactions",
"THIS_MAY_TAKE_A_FEW_MINUTES": "This may take a few minutes, please keep this window open.",
@@ -348,6 +353,8 @@
"ADDRESS_MISMATCH": "There’s a mismatch between your signing address and the address you’re logged with.",
"PSBT_NO_BROADCAST_DISCLAIMER": "This transaction will not be broadcasted from your wallet. It may be broadcasted later by a third party.",
"PSBTS_NO_BROADCAST_DISCLAIMER": "These transactions will not be broadcasted from your wallet. They may be broadcasted later by a third party.",
+ "PSBT_SIG_HASH_NONE_DISCLAIMER_TITLE": "Transaction uses SIGHASH_NONE",
+ "PSBT_SIG_HASH_NONE_DISCLAIMER": "Signing this transaction gives the requester full authority over your funds; they may alter fund destinations. Use with understanding of potential fund loss.",
"PSBT_CANT_PARSE_ERROR_TITLE": "Transaction Error",
"PSBT_CANT_PARSE_ERROR_DESCRIPTION": "The requested transaction is invalid and cannot be processed. Please contact the developer of the requesting app for support.",
"PSBT_INDEX_CANT_PARSE_ERROR_DESCRIPTION": "The requested transaction at {{index}} index is invalid and cannot be processed. Please contact the developer of the requesting app for support.",
@@ -355,6 +362,8 @@
"To": "To",
"YOU_WILL_TRANSFER": "You will transfer",
"YOU_WILL_RECEIVE": "You will receive",
+ "YOU_WILL_BURN": "You will burn",
+ "YOU_WILL_MINT": "You will mint",
"YOU_WILL_TRANSFER_IN_TOTAL": "You will transfer in total",
"YOU_WILL_RECEIVE_IN_TOTAL": "You will receive in total",
"REVIEW_ALL": "Review all",
@@ -365,10 +374,11 @@
"ORDINAL_DETECTED_ACTION": "Move to my ordinals address",
"BTC_TRANSFER_DANGER_ALERT_TITLE": "Danger",
"BTC_TRANSFER_DANGER_ALERT_DESC": "You are about to make a Bitcoin transfer which contains an ordinal inscription. Once transferred out of the wallet, you will not be able to recover them.",
- "CONITNUE": "Continue",
+ "CONTINUE": "Continue",
"BACK": "Back",
"HIGH_FEE_WARNING_TEXT": "The estimated transaction fee for this transaction is very high.",
"UNCONFIRMED_BALANCE_WARNING": "You are spending unconfirmed outputs in this transaction. This may lower the effective fee rate causing delays in transaction confirmation",
+ "RUNES_CENOTAPH_WARNING": "This transaction will burn all input Runes. Make sure you trust the requesting app.",
"LEDGER": {
"CONNECT": {
"TITLE": "Connect your hardware wallet",
@@ -386,7 +396,13 @@
"TITLE": "Transaction broadcasted",
"SUBTITLE": "Your transaction have been successfully broadcasted. You can close this tab."
},
+ "INPUTS_WARNING": {
+ "EXTERNAL_INPUTS": "External inputs",
+ "NON_DEFAULT_SIGHASH": "Non-default sighash",
+ "SUBTITLE": "Your device may display a warning regarding external inputs and the use of default sighash signature type. This is expected as you'll be signing inputs from two addresses in one transaction, using a non-default signature type."
+ },
"CONFIRM_BUTTON": "Confirm",
+ "CONTINUE_BUTTON": "Continue",
"CONNECT_BUTTON": "Connect",
"CLOSE_BUTTON": "Close",
"CANCEL_BUTTON": "Cancel",
@@ -398,7 +414,12 @@
"INSCRIBED_SATS": "Some of these sats are inscribed",
"INSCRIBED_RARE_SATS": "Some of these sats are rare or inscribed",
"UNCONFIRMED_UTXO_WARNING": "You are spending unconfirmed outputs in this transaction. This may lower the effective fee rate causing delays in transaction confirmation",
- "INSCRIBED_RARE_SATS_WARNING": "Your payment wallet holds rare or inscribed sats. To avoid spending them in your transactions and fees, transfer them to your ordinals wallet"
+ "INSCRIBED_RARE_SATS_WARNING": "Your payment wallet holds rare or inscribed sats. To avoid spending them in your transactions and fees, transfer them to your ordinals wallet",
+ "RUNE_TERM_ENDED": "This rune has passed its mint term and can no longer be minted.",
+ "RUNE_IS_CLOSED": "This rune is closed and cannot be minted.",
+ "UNKNOWN_RUNE_RECIPIENTS": "Undetermined Runes recipients ",
+ "RUNE_DELEGATION_DESCRIPTION": "This is a partial transaction with undetermined Runes recipients. You are delegating your Runes to the requesting app. The requesting app can burn your runes or transfer them to any recipient",
+ "YOU_WILL_DELEGATE": "You will delegate"
},
"TX_ERRORS": {
"INSUFFICIENT_BALANCE": "The requested transaction cannot be created due to insufficient balance",
@@ -604,8 +625,8 @@
"ERROR_RETRIEVING": "We are having trouble retrieving data.",
"TRY_AGAIN": "Please try again later.",
"LOAD_MORE": "Load more",
- "TOTAL_ITEMS_one": "{{count}} item",
- "TOTAL_ITEMS_other": "{{count}} items",
+ "TOTAL_ITEMS_ONE": "1 item",
+ "TOTAL_ITEMS": "{{count}} items",
"WEB_GALLERY": "Open gallery",
"RECEIVE": "Receive",
"SEND": "Send",
@@ -645,15 +666,17 @@
"HOLDS_RARE_SAT": "This inscription holds a rare sat."
},
"RESTORE_FUND_SCREEN": {
- "TITLE": "Restore assets",
+ "TITLE": "Recover assets",
"DESCRIPTION": "If you sent Ordinals to your Bitcoin payment address or Bitcoin to your ordinals address, you can recover it and send it to the correct address.",
"RECOVER_BTC": "Recover BTC",
"RECOVER_BTC_DESC": "If you sent Bitcoin to your Ordinals address.",
"RECOVER_ORDINALS": "Recover Ordinals",
- "RECOVER_ORDINALS_DESC": "If you sent Ordinals to your Bitcoin payment address."
+ "RECOVER_ORDINALS_DESC": "If you sent Ordinals to your Bitcoin payment address.",
+ "RECOVER_RUNES": "Recover Runes",
+ "RECOVER_RUNES_DESC": "If you sent Runes to your Bitcoin payment address."
},
"RESTORE_BTC_SCREEN": {
- "TITLE": "Restore BTC",
+ "TITLE": "Recover BTC",
"BTC": "Bitcoin",
"TRANSFER": "Transfer",
"BACK": "Back",
@@ -662,7 +685,7 @@
"DESCRIPTION": "You have Bitcoin stored in your ordinal address. You can transfer them to your payment address so they can be used for payments and are shown in your balance."
},
"RESTORE_ORDINAL_SCREEN": {
- "TITLE": "Restore Ordinals",
+ "TITLE": "Recover Ordinals",
"BTC": "Bitcoin",
"TRANSFER": "Transfer",
"BACK": "Back",
@@ -670,6 +693,15 @@
"NO_FUNDS": "You don’t have any Ordinals stored on your Bitcoin payment address.",
"DESCRIPTION": "You have Ordinals stored in your Bitcoin payment address. You can transfer them to your Ordinals address so that they show up in your collection."
},
+ "RECOVER_RUNES_SCREEN": {
+ "TITLE": "Recover Runes",
+ "DESCRIPTION": "Your Bitcoin payment address holds some Runes. To avoid accidentally spending them in Bitcoin payments, you can transfer them to your ordinals address, where you can manage your Runes portfolio",
+ "NO_RUNES": "You don't have any Runes stored on your Bitcoin payment address.",
+ "INSUFFICIENT_FUNDS": "You don't have enough bitcoin to cover the restore transaction.",
+ "CONFIRM": "Confirm",
+ "BACK": "Back",
+ "TRANSFER_ALL": "Transfer all"
+ },
"NFT_DETAIL_SCREEN": {
"NFT_DETAIL": "Item detail",
"WEB_GALLERY": "Open gallery",
@@ -1226,13 +1258,16 @@
"OMEGA": "Omega",
"FIRST_TRANSACTION": "First transaction",
"BLOCK9": "Block 9",
+ "BLOCK9_450": "Block 9 450",
"BLOCK78": "Block 78",
+ "BLOCK286": "Block 286",
"NAKAMOTO": "Nakamoto",
"VINTAGE": "Vintage",
"PIZZA": "Pizza",
"JPEG": "Jpeg",
"HITMAN": "Hitman",
- "SILK_ROAD": "Silkroad"
+ "SILK_ROAD": "Silkroad",
+ "LEGACY": "Legacy Sats"
},
"RARITY_DETAIL": {
"LEARN_MORE": "Learn more",
@@ -1261,13 +1296,16 @@
"OMEGA": "The last sats in each bitcoin. They always end in at least 8 nines.",
"FIRST_TRANSACTION": "Sats from the 10 bitcoins Satoshi Nakamoto sent Hal Finney in the first bitcoin transaction.",
"BLOCK9": "Sats mined in Block 9 (the first block with sats circulating today).",
+ "BLOCK9_450": "Sats in the first bitcoin of the 9th block. All sats from 45,000,000,000 to 45,099,999,999 (included).",
"BLOCK78": "Sats mined by Hal Finney in Block 78 (the first block mined by someone other than Satoshi).",
+ "BLOCK286": "Sats mined in the second-ever Bitcoin transaction, made and mined by Satoshi Nakamoto.",
"NAKAMOTO": "Sats mined by Satoshi Nakamoto himself.",
"VINTAGE": "Sats mined in the first 1000 bitcoin blocks.",
"PIZZA": "Sats involved in the famous pizza transaction from 2010.",
"JPEG": "Sats involved in the possible first bitcoin trade for an image on February 24, 2010.",
- "HITMAN": "Sats involved in the transaction made by Ross Ulbricht to hire a hitman.",
- "SILK_ROAD": "Sats seized from Silk Road and auctioned off on June 27, 2014 by US Marshals."
+ "HITMAN": "Sats involved in a transaction allegedly made by Ross Ulbricht to hire a Hitman.",
+ "SILK_ROAD": "Sats seized from Silk Road and auctioned off on June 27, 2014 by US Marshals.",
+ "LEGACY": "Sats that were given out in paper wallets during Casey Rodarmor's Bitcoin workshop back in June 2022."
},
"SAT_TYPES": {
"RARE_SAT": "{{type}} Sat",
@@ -1295,5 +1333,13 @@
"ERRORS": {
"FAILED_TO_FETCH": "Failed to fetch data"
}
+ },
+ "REQUEST_ERRORS": {
+ "INVALID_REQUEST": "Invalid request",
+ "MISSING_ARGUMENTS": "Contract function call missing arguments",
+ "ADDRESS_MISMATCH": "There’s a mismatch between your signing address and the address you’re logged with.",
+ "NETWORK_MISMATCH": "There’s a mismatch between your active network and the network you’re logged with.",
+ "PSBT_CANT_PARSE_ERROR_TITLE": "Transaction Error",
+ "PSBT_CANT_PARSE_ERROR_DESCRIPTION": "The requested transaction is invalid and cannot be processed. Please contact the developer of the requesting app for support."
}
}
diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts
new file mode 100644
index 000000000..0e5834a46
--- /dev/null
+++ b/tests/fixtures/base.ts
@@ -0,0 +1,32 @@
+import path from 'path';
+
+import { BrowserContext, Page, test as baseTest, chromium } from '@playwright/test';
+
+export const test = baseTest.extend<{
+ context: BrowserContext;
+ extensionId: string;
+ page: Page;
+}>({
+ // parts of the setup for the persistent context from https://playwright.dev/docs/chrome-extensions#testing
+ context: async ({}, use) => {
+ const extPath = process.env.BUILD_EXTENSION_PATH || path.join(__dirname, '../../build');
+ const context = await chromium.launchPersistentContext('', {
+ args: [`--disable-extensions-except=${extPath}`, `--load-extension=${extPath}`],
+ // slowMo: 400, // Slows down Playwright operations by 400 milliseconds for showcasing or testing reasons
+ });
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+ await use(context);
+ },
+ extensionId: async ({ context }, use) => {
+ let [background] = context.serviceWorkers();
+ if (!background) background = await context.waitForEvent('serviceworker');
+
+ const extId = background.url().split('/')[2];
+ await use(extId);
+ },
+ page: async ({ context }, use) => {
+ await use(context.pages()[0]);
+ },
+});
+
+export const { expect } = baseTest;
diff --git a/tests/fixtures/passwordTestData.ts b/tests/fixtures/passwordTestData.ts
new file mode 100644
index 000000000..0da66f7ec
--- /dev/null
+++ b/tests/fixtures/passwordTestData.ts
@@ -0,0 +1,37 @@
+// ToDo: fill with better passworddata
+export const passwordTestCases = [
+ {
+ password: '123',
+ expectations: {
+ errorMessageVisible: true,
+ securityLevel: 'Weak',
+ continueButtonEnabled: false,
+ },
+ },
+ {
+ password: '123456789',
+ expectations: {
+ errorMessageVisible: true,
+ securityLevel: 'Weak',
+ continueButtonEnabled: false,
+ },
+ },
+ {
+ password: 'Admin@1234',
+ expectations: {
+ errorMessageVisible: false,
+ securityLevel: 'Medium',
+ continueButtonEnabled: true,
+ },
+ },
+ {
+ password: 'Admin@1234!!',
+ expectations: {
+ errorMessageVisible: false,
+ securityLevel: 'Strong',
+ continueButtonEnabled: true,
+ },
+ },
+];
+
+module.exports = { passwordTestCases };
diff --git a/tests/pages/landing.ts b/tests/pages/landing.ts
new file mode 100644
index 000000000..1d3b613e3
--- /dev/null
+++ b/tests/pages/landing.ts
@@ -0,0 +1,23 @@
+import { Locator, Page, expect } from '@playwright/test';
+// Pageobject for landing page under options.html#/landing
+export default class Landing {
+ readonly buttonCreateWallet: Locator;
+
+ readonly buttonRestoreWallet: Locator;
+
+ readonly landingTitle: Locator;
+
+ constructor(readonly page: Page) {
+ this.page = page;
+ this.buttonCreateWallet = page.getByRole('button', { name: 'Create a new wallet' });
+ this.buttonRestoreWallet = page.getByRole('button', { name: 'Restore an existing wallet' });
+ this.landingTitle = page.getByText('The Bitcoin wallet for everyone');
+ }
+
+ // Initialization Method for intial visual check of page object
+ async initialize() {
+ await expect(this.buttonCreateWallet).toBeVisible();
+ await expect(this.buttonRestoreWallet).toBeVisible();
+ await expect(this.landingTitle).toBeVisible();
+ }
+}
diff --git a/tests/pages/onboarding.ts b/tests/pages/onboarding.ts
new file mode 100644
index 000000000..f8b2b3d25
--- /dev/null
+++ b/tests/pages/onboarding.ts
@@ -0,0 +1,270 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+import crypto from 'crypto';
+import Landing from './landing';
+
+export default class Onboarding {
+ readonly linkTOS: Locator;
+
+ readonly linkPrivacy: Locator;
+
+ readonly buttonAccept: Locator;
+
+ readonly buttonBackupNow: Locator;
+
+ readonly buttonBackupLater: Locator;
+
+ readonly imageBackup: Locator;
+
+ readonly titleBackupOnboarding: Locator;
+
+ readonly subTitleBackupOnboarding: Locator;
+
+ readonly buttonBack: Locator;
+
+ readonly inputPassword: Locator;
+
+ readonly errorMessage: Locator;
+
+ readonly errorMessage2: Locator;
+
+ readonly errorMessageSeedPhrase: Locator;
+
+ readonly buttonContinue: Locator;
+
+ readonly labelSecurityLevelWeak: Locator;
+
+ readonly labelSecurityLevelMedium: Locator;
+
+ readonly labelSecurityLevelStrong: Locator;
+
+ readonly firstParagraphBackupStep: Locator;
+
+ readonly buttonShowSeed: Locator;
+
+ readonly secondParagraphBackupStep: Locator;
+
+ readonly textSeedWords: Locator;
+
+ readonly buttonSeedWords: Locator;
+
+ readonly header: Locator;
+
+ readonly instruction: Locator;
+
+ readonly headingWalletRestored: Locator;
+
+ readonly buttonCloseTab: Locator;
+
+ readonly imageSuccess: Locator;
+
+ readonly headingRestoreWallet: Locator;
+
+ readonly button24SeedPhrase: Locator;
+
+ readonly button12SeedPhrase: Locator;
+
+ readonly inputSeedPhraseWord: Locator;
+
+ readonly inputSeedPhraseWordDisabled: Locator;
+
+ readonly buttonUnlock: Locator;
+
+ constructor(readonly page: Page) {
+ this.page = page;
+
+ this.buttonContinue = page.getByRole('button', { name: 'Continue' });
+ this.buttonBack = page.getByRole('button', { name: 'Back' });
+ this.buttonBackupNow = page.getByRole('button', { name: 'Backup now' });
+ this.buttonBackupLater = page.getByRole('button', { name: 'Backup later' });
+ this.imageBackup = page.locator('img[alt="backup"]');
+ this.titleBackupOnboarding = page.getByRole('heading', { name: 'Backup' });
+ this.subTitleBackupOnboarding = page.getByRole('heading', { name: 'Your seedphrase' });
+ this.firstParagraphBackupStep = page.locator('p').filter({ hasText: 'Write down your' });
+ this.buttonShowSeed = page.getByRole('button', { name: 'Show' });
+ this.secondParagraphBackupStep = page.getByRole('heading', { name: 'Confirm you' });
+ this.textSeedWords = page.locator('p[translate="no"]');
+ this.buttonSeedWords = page.locator('button[value]:not([value=""])');
+ // TODO: find more stable selector
+ this.header = page.locator('#app h3');
+ this.inputPassword = page.locator('input[type="password"]');
+ this.errorMessage = page.getByRole('heading', { name: 'Your password should be at' });
+ this.errorMessage2 = page.getByRole('heading', { name: 'Please make sure your' });
+ this.errorMessageSeedPhrase = page.locator('p').filter({ hasText: 'Invalid seed phrase' });
+ this.labelSecurityLevelWeak = page.locator('p').filter({ hasText: 'Weak' });
+ this.labelSecurityLevelMedium = page.locator('p').filter({ hasText: 'Medium' });
+ this.labelSecurityLevelStrong = page.locator('p').filter({ hasText: 'Strong' });
+ this.linkTOS = page.getByRole('link', { name: 'Terms of Service' });
+ this.linkPrivacy = page.getByRole('link', { name: 'Privacy Policy' });
+ this.buttonAccept = page.getByRole('button', { name: 'Accept' });
+ this.imageSuccess = page.locator('img[alt="success"]');
+ this.instruction = page.getByRole('heading', { name: 'Locate Xverse' });
+ this.headingWalletRestored = page.getByRole('heading', { name: 'Wallet restored' });
+ this.buttonCloseTab = page.getByRole('button', { name: 'Close this tab' });
+ this.headingRestoreWallet = page.getByRole('heading', { name: 'restore your wallet' });
+ this.button24SeedPhrase = page.getByRole('button', { name: '24 words' });
+ this.button12SeedPhrase = page.getByRole('button', { name: '12 words' });
+ this.inputSeedPhraseWord = page.locator('input');
+ this.inputSeedPhraseWordDisabled = page.locator('input[disabled]');
+ this.buttonUnlock = page.getByRole('button', { name: 'Unlock' });
+ }
+
+ // id starts from 0
+ inputWord = (id: number) => this.page.locator(`#input${id}`);
+
+ async selectSeedWord(seedWords: string[]): Promise {
+ const digitsOnly = ((await this.header.last().textContent()) as string).replace(/\D/g, '');
+ const seedWord = seedWords[Number(digitsOnly) - 1];
+ return seedWord;
+ }
+
+ async checkLegalPage(context) {
+ await expect(this.page.url()).toContain('legal');
+ // TODO: Selector outsource
+ await expect(this.page.locator('div > h1:first-child')).toHaveText(/Legal/);
+ // check that the links contain href values
+ // TODO better selectors for link selection
+ const linkList = this.page.locator('#app a');
+ for (let i = 0; i < (await linkList.count()); i++) {
+ expect(await linkList.nth(i).getAttribute('href')).not.toBeNull();
+ }
+ await expect(this.page.locator('input[type="checkbox"]')).toBeVisible();
+ await expect(this.buttonAccept).toBeVisible();
+
+ // check links
+ await this.linkTOS.click();
+ await context.waitForEvent('page');
+ // To check the newest open Tab
+ let newPage = await context.pages()[context.pages().length - 1];
+ await newPage.waitForURL('https://www.xverse.app/terms');
+ await newPage.close();
+ await this.linkPrivacy.click();
+ await context.waitForEvent('page');
+ newPage = await context.pages()[context.pages().length - 1];
+ await newPage.waitForURL('https://www.xverse.app/privacy');
+ await newPage.close();
+ }
+
+ async navigateToBackupPage() {
+ const landingpage = new Landing(this.page);
+ await landingpage.buttonCreateWallet.click();
+ await expect(this.page.url()).toContain('legal');
+ await this.buttonAccept.click();
+ await expect(this.page.url()).toContain('backup');
+ }
+
+ async checkBackupPage() {
+ await expect(this.buttonBackupNow).toBeVisible();
+ await expect(this.buttonBackupLater).toBeVisible();
+ await expect(this.imageBackup).toBeVisible();
+ await expect(this.titleBackupOnboarding).toBeVisible();
+ await expect(this.subTitleBackupOnboarding).toBeVisible();
+ }
+
+ async navigateToRestorePage() {
+ const landingpage = new Landing(this.page);
+ await expect(landingpage.buttonRestoreWallet).toBeVisible();
+ await landingpage.buttonRestoreWallet.click();
+ await expect(this.page.url()).toContain('legal');
+ await this.buttonAccept.click();
+ await expect(this.page.url()).toContain('restore');
+ await this.checkRestoreWalletSeedPhrasePage();
+ }
+
+ async checkRestoreWalletSeedPhrasePage() {
+ await expect(this.buttonContinue).toBeDisabled();
+ await expect(this.headingRestoreWallet).toBeVisible();
+ await expect(this.button24SeedPhrase).toBeVisible();
+ await expect(this.inputSeedPhraseWordDisabled).toHaveCount(12);
+ await expect(this.inputSeedPhraseWord).toHaveCount(24);
+ }
+
+ // Check the viuals on the first password page before inputing any values in the input field
+ async checkPasswordPage() {
+ await expect(this.buttonBack).toBeVisible();
+ await expect(this.inputPassword).toBeVisible();
+ await expect(this.buttonContinue).toBeVisible();
+ await expect(this.buttonContinue).toBeDisabled();
+ await expect(this.errorMessage).toBeHidden();
+ await expect(this.labelSecurityLevelWeak).toBeHidden();
+ await expect(this.labelSecurityLevelMedium).toBeHidden();
+ await expect(this.labelSecurityLevelStrong).toBeHidden();
+ }
+
+ static async multipleClickCheck(button: Locator) {
+ await button.click();
+ await button.click();
+ await button.click();
+ }
+
+ async createWalletSkipBackup(password) {
+ await this.navigateToBackupPage();
+ await this.buttonBackupLater.click();
+ await expect(this.page.url()).toContain('create-password');
+ await this.inputPassword.fill(password);
+ await this.buttonContinue.click();
+ await this.inputPassword.fill(password);
+ await this.buttonContinue.click();
+ await expect(this.imageSuccess).toBeVisible();
+ }
+
+ async testPasswordInput({ password, expectations }) {
+ // Fill in the password input field with the specified password.
+ await this.inputPassword.fill(password);
+
+ // Check if an error message is expected to be visible.
+ if (expectations.errorMessageVisible) {
+ // If yes, verify that the error message element is visible.
+ await expect(this.errorMessage).toBeVisible();
+ } else {
+ // If not, verify that the error message element is hidden.
+ await expect(this.errorMessage).toBeHidden();
+ }
+
+ // Define a mapping of security levels to their corresponding label elements.
+ const visibilityChecks = {
+ Weak: this.labelSecurityLevelWeak,
+ Medium: this.labelSecurityLevelMedium,
+ Strong: this.labelSecurityLevelStrong,
+ };
+
+ // Concurrently verify the visibility of each security level label.
+ await Promise.all(
+ Object.entries(visibilityChecks).map(async ([level, element]) => {
+ // If the current level matches the expected security level, check that its label is visible.
+ if (expectations.securityLevel === level) {
+ await expect(element).toBeVisible();
+ } else {
+ // Otherwise, ensure the label is hidden.
+ await expect(element).toBeHidden();
+ }
+ }),
+ );
+
+ // Check if the continue button is expected to be enabled.
+ if (expectations.continueButtonEnabled) {
+ // If yes, verify that the continue button is enabled.
+ await expect(this.buttonContinue).toBeEnabled();
+ } else {
+ // If not, verify that the continue button is disabled.
+ await expect(this.buttonContinue).toBeDisabled();
+ }
+
+ // Clear the password input field after all checks are done.
+ await this.inputPassword.clear();
+ }
+
+ static generateSecurePasswordCrypto() {
+ const length = 9;
+ const charset =
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':,.<>/?`~";
+
+ let password = '';
+ while (password.length < length) {
+ // Generate a random byte
+ const randomValue = crypto.randomInt(charset.length);
+ password += charset[randomValue];
+ }
+
+ return password;
+ }
+}
diff --git a/tests/pages/wallet.ts b/tests/pages/wallet.ts
new file mode 100644
index 000000000..56e96e244
--- /dev/null
+++ b/tests/pages/wallet.ts
@@ -0,0 +1,351 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+export default class Wallet {
+ readonly balance: Locator;
+
+ readonly allupperButtons: Locator;
+
+ readonly labelAccountName: Locator;
+
+ readonly buttonGenerateAccount: Locator;
+
+ readonly buttonConnectHardwareWallet: Locator;
+
+ readonly inputName: Locator;
+
+ readonly buttonAccountOptions: Locator;
+
+ readonly accountBalance: Locator;
+
+ readonly buttonRenameAccount: Locator;
+
+ readonly buttonResetAccountName: Locator;
+
+ readonly labelInfoRenameAccount: Locator;
+
+ readonly errorMessageRenameAccount: Locator;
+
+ readonly manageTokenButton: Locator;
+
+ readonly buttonMenu: Locator;
+
+ readonly buttonLock: Locator;
+
+ readonly buttonConfirm: Locator;
+
+ readonly buttonResetWallet: Locator;
+
+ readonly buttonDenyDataCollection: Locator;
+
+ readonly buttonCopyBitcoinAddress: Locator;
+
+ readonly buttonCopyOrdinalsAddress: Locator;
+
+ readonly buttonCopyStacksAddress: Locator;
+
+ readonly buttonConfirmCopyAddress: Locator;
+
+ readonly buttonNetwork: Locator;
+
+ readonly buttonSave: Locator;
+
+ readonly buttonMainnet: Locator;
+
+ readonly buttonTestnet: Locator;
+
+ readonly buttonBack: Locator;
+
+ readonly inputStacksURL: Locator;
+
+ readonly inputBTCURL: Locator;
+
+ readonly inputFallbackBTCURL: Locator;
+
+ readonly labelCoinTitle: Locator;
+
+ readonly checkboxToken: Locator;
+
+ readonly checkboxTokenActive: Locator;
+
+ readonly checkboxTokenInactive: Locator;
+
+ readonly buttonSip10: Locator;
+
+ readonly buttonBRC20: Locator;
+
+ readonly buttonRunes: Locator;
+
+ readonly headingTokens: Locator;
+
+ readonly divTokenRow: Locator;
+
+ readonly labelTokenSubtitle: Locator;
+
+ readonly labelCoinBalance: Locator;
+
+ readonly navigationDashboard: Locator;
+
+ readonly navigationNFT: Locator;
+
+ readonly navigationStacking: Locator;
+
+ readonly navigationExplore: Locator;
+
+ readonly navigationSettings: Locator;
+
+ readonly divAppSlide: Locator;
+
+ readonly divAppCard: Locator;
+
+ readonly divAppTitle: Locator;
+
+ readonly carouselApp: Locator;
+
+ constructor(readonly page: Page) {
+ this.page = page;
+ this.navigationDashboard = page.getByTestId('nav-dashboard');
+ this.navigationNFT = page.getByTestId('nav-nft');
+ this.navigationStacking = page.getByTestId('nav-stacking');
+ this.navigationExplore = page.getByTestId('nav-explore');
+ this.navigationSettings = page.getByTestId('nav-settings');
+ this.balance = page.getByTestId('total-balance-value');
+ this.allupperButtons = page.getByTestId('transaction-buttons-row').getByRole('button');
+ this.labelAccountName = page.getByLabel('Account Name');
+ this.buttonAccountOptions = page.getByLabel('Open Account Options');
+ this.accountBalance = page.getByTestId('account-balance');
+ this.buttonRenameAccount = page.getByRole('button', { name: 'Rename account' });
+ this.buttonResetAccountName = page.getByRole('button', { name: 'Reset name' });
+ this.labelInfoRenameAccount = page
+ .locator('form div')
+ .filter({ hasText: 'name can only include alphabetical and numerical' });
+ this.buttonGenerateAccount = page.getByRole('button', { name: 'Generate account' });
+ this.buttonConnectHardwareWallet = page.getByRole('button', {
+ name: 'Connect hardware wallet',
+ });
+ this.inputName = page.locator('input[type="text"]');
+ this.errorMessageRenameAccount = page
+ .locator('p')
+ .filter({ hasText: 'contain alphabetic and numeric' });
+ this.manageTokenButton = page.getByRole('button', { name: 'Manage token list' });
+ this.buttonMenu = page.getByRole('button', { name: 'Open Header Options' });
+ this.buttonLock = page.getByRole('button', { name: 'Lock' });
+ this.buttonConfirm = page.getByRole('button', { name: 'Confirm' });
+ this.buttonResetWallet = page.getByRole('button', { name: 'Reset Wallet' });
+ this.buttonDenyDataCollection = page.getByRole('button', { name: 'Deny' });
+ this.buttonCopyBitcoinAddress = page.locator('#copy-address-Bitcoin');
+ this.buttonCopyOrdinalsAddress = page.locator(
+ '#copy-address-Ordinals\\,\\ BRC-20\\ \\&\\ Runes',
+ );
+ this.buttonCopyStacksAddress = page.locator(
+ '#copy-address-Stacks\\ NFTs\\ \\&\\ SIP-10\\ tokens',
+ );
+
+ this.buttonConfirmCopyAddress = page.getByRole('button', { name: 'I understand' });
+
+ // Settings network
+ this.buttonNetwork = page.getByRole('button', { name: 'Network' });
+ this.buttonSave = page.getByRole('button', { name: 'Save' });
+ this.buttonMainnet = page.getByRole('button', { name: 'Mainnet' });
+ this.buttonTestnet = page.getByRole('button', { name: 'Testnet' });
+ this.buttonBack = page.getByRole('button', { name: 'back button' });
+ this.inputStacksURL = page.getByTestId('Stacks URL');
+ this.inputBTCURL = page.getByTestId('BTC URL');
+ this.inputFallbackBTCURL = page.getByTestId('Fallback BTC URL');
+
+ // Token
+ this.labelCoinTitle = page.getByLabel('Coin Title');
+ this.checkboxToken = page.locator('input[type="checkbox"]');
+ this.checkboxTokenActive = page.locator('input[type="checkbox"]:checked');
+ this.checkboxTokenInactive = page.locator('input[type="checkbox"]:not(:checked)');
+ this.buttonSip10 = page.getByRole('button', { name: 'SIP-10' });
+ this.buttonBRC20 = page.getByRole('button', { name: 'BRC-20' });
+ this.buttonRunes = page.getByRole('button', { name: 'RUNES' });
+ this.headingTokens = page.getByRole('heading', { name: 'Manage tokens' });
+ this.divTokenRow = page.getByLabel('Token Row');
+ this.labelTokenSubtitle = page.getByLabel('Token SubTitle');
+ this.labelCoinBalance = page.getByLabel('CoinBalance Container').locator('span');
+
+ // Explore
+ this.carouselApp = page.getByTestId('app-carousel');
+ this.divAppSlide = page.getByTestId('app-slide');
+ this.divAppCard = page.getByTestId('app-card');
+ this.divAppTitle = page.getByTestId('app-title');
+ }
+
+ async checkVisualsStartpage() {
+ // Deny data collection --> modal window is not always appearing so when it does we deny the data collection
+ if (await this.buttonDenyDataCollection.isVisible()) {
+ await this.buttonDenyDataCollection.click();
+ }
+ await expect(this.balance).toBeVisible();
+ await expect(this.manageTokenButton).toBeVisible();
+ await expect(this.buttonMenu).toBeVisible();
+ // Check if all 4 buttons (send, receive, swap, buy) are visible
+ await expect(this.allupperButtons).toHaveCount(4);
+ await expect(this.labelAccountName).toBeVisible();
+ await expect(this.labelTokenSubtitle).toHaveCount(2);
+
+ await expect(this.navigationDashboard).toBeVisible();
+ await expect(this.navigationNFT).toBeVisible();
+ await expect(this.navigationStacking).toBeVisible();
+ await expect(this.navigationExplore).toBeVisible();
+ await expect(this.navigationSettings).toBeVisible();
+ }
+
+ async getAddress(button) {
+ await expect(button).toBeVisible();
+ await button.click();
+ await expect(this.buttonConfirmCopyAddress).toBeVisible();
+ await this.buttonConfirmCopyAddress.click();
+ const address = await this.page.evaluate('navigator.clipboard.readText()');
+ return address;
+ }
+
+ async checkNetworkSettingVisuals() {
+ await expect(this.buttonSave).toBeVisible();
+ await expect(this.buttonBack).toBeVisible();
+ await expect(this.buttonMainnet).toBeVisible();
+ await expect(this.buttonTestnet).toBeVisible();
+ await expect(this.inputStacksURL).toBeVisible();
+ await expect(this.inputBTCURL).toBeVisible();
+ await expect(this.inputFallbackBTCURL).toBeVisible();
+ }
+
+ async checkTestnetUrls(shouldContainTestnet) {
+ const inputsURL = [this.inputStacksURL, this.inputBTCURL, this.inputFallbackBTCURL];
+ const checks = inputsURL.map(async (input) => {
+ const inputValue = await input.inputValue();
+ const message = `URL does not contain 'testnet' in ${input}`;
+ if (shouldContainTestnet) {
+ return expect(inputValue, message).toContain('testnet');
+ }
+ return expect(inputValue, message).not.toContain('testnet');
+ });
+ await Promise.all(checks);
+ }
+
+ async switchtoTestnetNetwork() {
+ await expect(this.buttonNetwork).toBeVisible();
+ await expect(this.buttonNetwork).toHaveText('NetworkMainnet');
+ await this.buttonNetwork.click();
+ await this.checkNetworkSettingVisuals();
+ await expect(this.buttonMainnet.locator('img')).toHaveAttribute('alt', 'tick');
+ await expect(this.buttonTestnet.locator('img[alt="tick"]')).toHaveCount(0);
+
+ await this.buttonTestnet.click();
+ await expect(this.buttonTestnet.locator('img')).toHaveAttribute('alt', 'tick');
+ await expect(this.buttonMainnet.locator('img[alt="tick"]')).toHaveCount(0);
+
+ await this.checkTestnetUrls(true);
+
+ await this.buttonSave.click();
+ await expect(this.buttonNetwork).toBeVisible();
+ await expect(this.buttonNetwork).toHaveText('NetworkTestnet');
+ }
+
+ async switchtoMainnetNetwork() {
+ await expect(this.buttonNetwork).toBeVisible();
+ await expect(this.buttonNetwork).toHaveText('NetworkTestnet');
+ await this.buttonNetwork.click();
+ await this.checkNetworkSettingVisuals();
+ await expect(this.buttonTestnet.locator('img')).toHaveAttribute('alt', 'tick');
+ await expect(this.buttonMainnet.locator('img[alt="tick"]')).toHaveCount(0);
+
+ await this.buttonMainnet.click();
+ await expect(this.buttonMainnet.locator('img')).toHaveAttribute('alt', 'tick');
+ await expect(this.buttonTestnet.locator('img[alt="tick"]')).toHaveCount(0);
+
+ await this.checkTestnetUrls(false);
+
+ await this.buttonSave.click();
+ await expect(this.buttonNetwork).toBeVisible();
+ await expect(this.buttonNetwork).toHaveText('NetworkMainnet');
+ }
+
+ async getBalanceOfAllAccounts() {
+ const count = await this.accountBalance.count();
+ let totalBalance = 0;
+ if (count > 1) {
+ for (let i = 0; i < count; i++) {
+ const balanceText = await this.accountBalance.nth(i).innerText();
+ const numericValue = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
+ totalBalance += numericValue;
+ }
+ } else {
+ const balanceText = await this.accountBalance.innerText();
+ totalBalance = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
+ }
+ return totalBalance;
+ }
+
+ async getBalanceOfAllTokens() {
+ const count = await this.labelCoinBalance.count();
+ let totalBalance = 0;
+ if (count > 1) {
+ for (let i = 0; i < count; i++) {
+ const balanceText = await this.labelCoinBalance.nth(i).innerText();
+ const numericValue = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
+ totalBalance += numericValue;
+ }
+ } else {
+ const balanceText = await this.labelCoinBalance.innerText();
+ totalBalance = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
+ }
+ // Check if total balance of all tokens is the same as total wallet balance
+ const totalBalanceText = await this.balance.innerText();
+ const totalBalanceWallet = parseFloat(totalBalanceText.replace(/[^\d.-]/g, ''));
+ await expect(totalBalanceWallet).toBe(totalBalance);
+ return totalBalance;
+ }
+
+ async enableARandomToken(): Promise {
+ const numberOfUnselectedTokens = await this.checkboxTokenInactive.count();
+
+ // Generate a random number within the range of available select elements
+ const chosenNumber = Math.floor(Math.random() * numberOfUnselectedTokens) + 1;
+
+ // Access the nth select element (note the adjustment for zero-based indexing)
+ const adjustChosenNumber = chosenNumber - 1;
+ const chosenUnselectedToken = this.divTokenRow
+ .filter({ has: this.checkboxTokenInactive })
+ .nth(adjustChosenNumber);
+ const enabledTokenName =
+ (await chosenUnselectedToken.getAttribute('data-testid')) || 'default-value';
+ await chosenUnselectedToken.locator('div.react-switch-handle').click();
+ return enabledTokenName;
+ }
+
+ async disableARandomToken(): Promise {
+ const numberOfUnselectedTokens = await this.checkboxTokenActive.count();
+
+ // Generate a random number within the range of available select elements
+ const chosenNumber = Math.floor(Math.random() * numberOfUnselectedTokens) + 1;
+
+ // Access the nth select element (note the adjustment for zero-based indexing)
+ const adjustChosenNumber = chosenNumber - 1;
+ const chosenUnselectedToken = this.divTokenRow
+ .filter({ has: this.checkboxTokenActive })
+ .nth(adjustChosenNumber);
+ const disabledTokenName =
+ (await chosenUnselectedToken.getAttribute('data-testid')) || 'default-value';
+ await chosenUnselectedToken.locator('div.react-switch-handle').click();
+ return disabledTokenName;
+ }
+
+ async disableAllTokens() {
+ const allActiveTokens = this.divTokenRow.filter({ has: this.checkboxTokenActive });
+ const count = await allActiveTokens.count();
+ for (let i = 0; i < count; i++) {
+ await allActiveTokens.first().locator('div.react-switch-handle').click();
+ }
+ }
+
+ async enableAllTokens() {
+ const allInactiveTokens = this.divTokenRow.filter({ has: this.checkboxTokenInactive });
+ const count = await allInactiveTokens.count();
+ for (let i = 0; i < count; i++) {
+ // We click the first inactive Token and when this inactive token becomes active we need to click the next one which becomes the first then
+ await allInactiveTokens.first().locator('div.react-switch-handle').click();
+ }
+ }
+}
diff --git a/tests/specs/createWallet.spec.ts b/tests/specs/createWallet.spec.ts
new file mode 100644
index 000000000..340eb8387
--- /dev/null
+++ b/tests/specs/createWallet.spec.ts
@@ -0,0 +1,191 @@
+import { expect, test } from '../fixtures/base';
+import Landing from '../pages/landing';
+import Onboarding from '../pages/onboarding';
+import Wallet from '../pages/wallet';
+
+test.beforeEach(async ({ page, extensionId, context }) => {
+ await page.goto(`chrome-extension://${extensionId}/options.html#/landing`);
+ // TODO: this fixes a temporary issue with two tabs at the start see technical debt https://linear.app/xverseapp/issue/ENG-3992/two-tabs-open-instead-of-one-since-version-0323-for-start-extension
+ const pages = await context.pages();
+ if (pages.length === 2) {
+ await pages[1].close(); // pages[1] is the second (newest) page
+ }
+});
+test.afterEach(async ({ context }) => {
+ if (context.pages().length > 0) {
+ // Close the context only if there are open pages
+ await context.close();
+ }
+});
+
+const strongPW = Onboarding.generateSecurePasswordCrypto();
+const fs = require('fs');
+const path = require('path');
+
+// Specify the file path for Addresses and Seedphrase
+const filePathSeedWords = path.join(__dirname, 'seedWords.json');
+const filePathAddresses = path.join(__dirname, 'addresses.json');
+
+test.describe('Create and Restore Wallet Flow', () => {
+ test('create and restore a wallet via Menu', async ({ page, extensionId, context }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await test.step('backup seedphrase and successfully create a wallet', async () => {
+ await onboardingpage.navigateToBackupPage();
+ await onboardingpage.buttonBackupNow.click();
+ await expect(page.url()).toContain('backupWalletSteps');
+ await expect(onboardingpage.buttonContinue).toBeDisabled();
+ await expect(onboardingpage.buttonShowSeed).toBeVisible();
+ await expect(onboardingpage.firstParagraphBackupStep).toBeVisible();
+ await onboardingpage.buttonShowSeed.click();
+ await expect(onboardingpage.buttonContinue).toBeEnabled();
+ const seedWords = await onboardingpage.textSeedWords.allTextContents();
+ await onboardingpage.buttonContinue.click();
+
+ // check if 12 words are displayed
+ await expect(onboardingpage.buttonSeedWords).toHaveCount(12);
+ await expect(onboardingpage.secondParagraphBackupStep).toBeVisible();
+ let seedword = await onboardingpage.selectSeedWord(seedWords);
+
+ // Save the seedwords into a file to read it out later to restore
+ fs.writeFileSync(filePathSeedWords, JSON.stringify(seedWords), 'utf8');
+
+ // get all displayed values and filter the value from the actual seedphrase out to do an error message check
+ const buttonValues = await onboardingpage.buttonSeedWords.evaluateAll((buttons) =>
+ buttons.map((button) => {
+ // Assert that the button is an HTMLButtonElement to access the `value` property
+ if (button instanceof HTMLButtonElement) {
+ return button.value;
+ }
+ return 'testvalue';
+ }),
+ );
+
+ const filteredValues = buttonValues.filter((value) => value !== seedword);
+ const randomValue = filteredValues[Math.floor(Math.random() * filteredValues.length)];
+ await page.locator(`button[value="${randomValue}"]`).click();
+
+ // Check if error message is displayed when clicking the wrong seedword
+ await expect(page.locator('p:has-text("This word is not")')).toBeVisible();
+
+ await page.locator(`button[value="${seedword}"]`).click();
+ seedword = await onboardingpage.selectSeedWord(seedWords);
+ await page.locator(`button[value="${seedword}"]`).click();
+ seedword = await onboardingpage.selectSeedWord(seedWords);
+ await page.locator(`button[value="${seedword}"]`).click();
+
+ await onboardingpage.inputPassword.fill(strongPW);
+ await onboardingpage.buttonContinue.click();
+ await onboardingpage.inputPassword.fill(strongPW);
+ await onboardingpage.buttonContinue.click();
+
+ await expect(onboardingpage.imageSuccess).toBeVisible();
+ await expect(onboardingpage.instruction).toBeVisible();
+ await expect(onboardingpage.buttonCloseTab).toBeVisible();
+
+ // Open the wallet directly via URL
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ const newWallet = new Wallet(page);
+ await newWallet.checkVisualsStartpage();
+
+ const balanceText = await newWallet.balance.innerText();
+ await expect(balanceText).toBe('$0.00');
+
+ // TODO: find better selector for the receive button
+ await newWallet.allupperButtons.nth(1).click();
+
+ // Get the addresses and save it in variables
+ const addressBitcoin = await newWallet.getAddress(newWallet.buttonCopyBitcoinAddress);
+ const addressOrdinals = await newWallet.getAddress(newWallet.buttonCopyOrdinalsAddress);
+ // Stack Address doesn't have the confirm message
+ await expect(newWallet.buttonCopyStacksAddress).toBeVisible();
+ await newWallet.buttonCopyStacksAddress.click();
+ const addressStack = await page.evaluate('navigator.clipboard.readText()');
+
+ // Reload the page to close the modal window for the addresses as the X button needs to have a better locator
+ await page.reload();
+ // click close for the modal window
+ // TODO find better locator for close button --> issue https://linear.app/xverseapp/issue/ENG-4039/adjust-id-or-add-titles-for-copy-address-button-for-receive-menu
+ // await expect(page.locator('button.sc-hceviv > svg')).toBeVisible();
+ // await page.locator('button.sc-hceviv > svg').click();
+
+ // Save the Address in a file so that other tests can access them
+ const dataAddress = JSON.stringify({
+ addressBitcoin,
+ addressOrdinals,
+ addressStack,
+ });
+
+ // Write the file
+ fs.writeFileSync(filePathAddresses, dataAddress, 'utf8');
+ });
+ await test.step('reset Wallet via Menu', async () => {
+ await expect(wallet.buttonMenu).toBeVisible();
+ await wallet.buttonMenu.click();
+ await expect(wallet.buttonResetWallet).toBeVisible();
+ await wallet.buttonResetWallet.click();
+ await wallet.buttonResetWallet.click();
+ await expect(onboardingpage.inputPassword).toBeVisible();
+ await onboardingpage.inputPassword.fill(strongPW);
+ await onboardingpage.buttonContinue.click();
+ });
+ await test.step('Restore wallet with 12 word seed phrase', async () => {
+ const landingpage = new Landing(page);
+ await expect(landingpage.buttonRestoreWallet).toBeVisible();
+ await landingpage.buttonRestoreWallet.click();
+
+ // Clicking on restore opens in this setup a new page for legal
+ const newPage = await context.pages()[context.pages().length - 1];
+ await expect(newPage.url()).toContain('legal');
+ // old page needs to be closed as the test is continuing in the new tab
+ const pages = await context.pages();
+ await pages[0].close(); // pages[0] is the first (oldest) page
+ const onboardingpage2 = new Onboarding(newPage);
+
+ await onboardingpage2.buttonAccept.click();
+ await expect(newPage.url()).toContain('restore');
+ await onboardingpage2.checkRestoreWalletSeedPhrasePage();
+
+ const seedWords = JSON.parse(fs.readFileSync(filePathSeedWords, 'utf8'));
+
+ for (let i = 0; i < seedWords.length; i++) {
+ await onboardingpage2.inputWord(i).fill(seedWords[i]);
+ }
+ await expect(onboardingpage2.buttonContinue).toBeEnabled();
+ await onboardingpage2.buttonContinue.click();
+ await onboardingpage2.inputPassword.fill(strongPW);
+ await onboardingpage2.buttonContinue.click();
+ await onboardingpage2.inputPassword.fill(strongPW);
+ await onboardingpage2.buttonContinue.click();
+ await expect(onboardingpage2.imageSuccess).toBeVisible();
+ await expect(onboardingpage2.headingWalletRestored).toBeVisible();
+ await expect(onboardingpage2.buttonCloseTab).toBeVisible();
+ // Open the wallet directly via URL
+ await newPage.goto(`chrome-extension://${extensionId}/popup.html`);
+ const newWallet = new Wallet(newPage);
+ await newWallet.checkVisualsStartpage();
+
+ const balanceText = await newWallet.balance.innerText();
+ await expect(balanceText).toBe('$0.00');
+
+ await newWallet.allupperButtons.nth(1).click();
+
+ // Get the Addresses
+ const addressBitcoinCheck = await newWallet.getAddress(newWallet.buttonCopyBitcoinAddress);
+ const addressOrdinalsCheck = await newWallet.getAddress(newWallet.buttonCopyOrdinalsAddress);
+ // Stack Address doesn't have the confirm message
+ await expect(newWallet.buttonCopyStacksAddress).toBeVisible();
+ await newWallet.buttonCopyStacksAddress.click();
+ const addressStackCheck = await newPage.evaluate('navigator.clipboard.readText()');
+
+ // Read and parse the file
+ const rawData = fs.readFileSync(filePathAddresses, 'utf8');
+ const { addressBitcoin, addressOrdinals, addressStack } = JSON.parse(rawData);
+
+ // Check if the Addresses are the same as from the file
+ await expect(addressBitcoin).toBe(addressBitcoinCheck);
+ await expect(addressOrdinals).toBe(addressOrdinalsCheck);
+ await expect(addressStack).toBe(addressStackCheck);
+ });
+ });
+});
diff --git a/tests/specs/exploreTab.spec.ts b/tests/specs/exploreTab.spec.ts
new file mode 100644
index 000000000..681e4109a
--- /dev/null
+++ b/tests/specs/exploreTab.spec.ts
@@ -0,0 +1,36 @@
+import { expect, test } from '../fixtures/base';
+import Onboarding from '../pages/onboarding';
+import Wallet from '../pages/wallet';
+
+const strongPW = Onboarding.generateSecurePasswordCrypto();
+
+test.describe('Explore Tab', () => {
+ test.beforeEach(async ({ page, extensionId, context }) => {
+ await page.goto(`chrome-extension://${extensionId}/options.html#/landing`);
+ // TODO: this fixes a temporary issue with two tabs at the start see technical debt https://linear.app/xverseapp/issue/ENG-3992/two-tabs-open-instead-of-one-since-version-0323-for-start-extension
+ const pages = await context.pages();
+ if (pages.length === 2) {
+ await pages[1].close(); // pages[1] is the second (newest) page
+ }
+ });
+ test.afterEach(async ({ context }) => {
+ if (context.pages().length > 0) {
+ // Close the context only if there are open pages
+ await context.close();
+ }
+ });
+
+ test('Check explore Tab', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.navigationExplore.click();
+ await expect(page.url()).toContain('explore');
+ await expect(wallet.carouselApp).toBeVisible();
+ // More than 1 app is shown in the carousel and recommended App
+ await expect(await wallet.divAppSlide.count()).toBeGreaterThan(1);
+ await expect(await wallet.divAppCard.count()).toBeGreaterThan(1);
+ await expect(await wallet.divAppTitle.count()).toBeGreaterThan(1);
+ });
+});
diff --git a/tests/specs/healthcheck.spec.ts b/tests/specs/healthcheck.spec.ts
new file mode 100644
index 000000000..e7ac4f771
--- /dev/null
+++ b/tests/specs/healthcheck.spec.ts
@@ -0,0 +1,14 @@
+import { test } from '../fixtures/base';
+import Landing from '../pages/landing';
+
+test.describe('healthcheck', () => {
+ test.afterEach(async ({ context }) => {
+ await context.close();
+ });
+
+ test('healthcheck #smoketest', async ({ page, extensionId }) => {
+ await page.goto(`chrome-extension://${extensionId}/options.html#/landing`);
+ const landingpage = new Landing(page);
+ await landingpage.initialize();
+ });
+});
diff --git a/tests/specs/managementAccount.spec.ts b/tests/specs/managementAccount.spec.ts
new file mode 100644
index 000000000..43763b480
--- /dev/null
+++ b/tests/specs/managementAccount.spec.ts
@@ -0,0 +1,139 @@
+import { expect, test } from '../fixtures/base';
+import Onboarding from '../pages/onboarding';
+import Wallet from '../pages/wallet';
+
+const strongPW = Onboarding.generateSecurePasswordCrypto();
+
+test.describe('Account Management', () => {
+ test.beforeEach(async ({ page, extensionId, context }) => {
+ await page.goto(`chrome-extension://${extensionId}/options.html#/landing`);
+ // TODO: this fixes a temporary issue with two tabs at the start see technical debt https://linear.app/xverseapp/issue/ENG-3992/two-tabs-open-instead-of-one-since-version-0323-for-start-extension
+ const pages = await context.pages();
+ if (pages.length === 2) {
+ await pages[1].close(); // pages[1] is the second (newest) page
+ }
+ });
+ test.afterEach(async ({ context }) => {
+ if (context.pages().length > 0) {
+ // Close the context only if there are open pages
+ await context.close();
+ }
+ });
+
+ test('Check account page #smoketest', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.labelAccountName.click();
+ await expect(page.url()).toContain('account-list');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ await expect(wallet.buttonGenerateAccount).toBeVisible();
+ await expect(wallet.buttonConnectHardwareWallet).toBeVisible();
+ await expect(wallet.buttonBack).toBeVisible();
+ await expect(wallet.buttonAccountOptions).toBeVisible();
+ await expect(wallet.accountBalance).toBeVisible();
+ const balanceText = await wallet.accountBalance.innerText();
+ await expect(balanceText).toBe('$0.00');
+ await wallet.buttonBack.click();
+ await wallet.checkVisualsStartpage();
+ await expect(wallet.labelAccountName).toHaveText('Account 1');
+ });
+
+ test('Rename account', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.labelAccountName.click();
+ await expect(page.url()).toContain('account-list');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ await wallet.buttonAccountOptions.click();
+ await expect(wallet.buttonRenameAccount).toBeVisible();
+ await wallet.buttonRenameAccount.click();
+ await expect(wallet.buttonConfirm).toBeVisible();
+ await expect(wallet.buttonConfirm).toBeDisabled();
+ await expect(wallet.labelInfoRenameAccount).toBeVisible();
+ await expect(wallet.inputName).toBeVisible();
+ await expect(wallet.buttonResetAccountName).toBeVisible();
+ await expect(wallet.errorMessageRenameAccount).toBeHidden();
+ // Check Errormessage for non alphabetical and numerical characters
+ await wallet.inputName.fill(`!!!`);
+ await expect(wallet.errorMessageRenameAccount).toBeVisible();
+ await expect(wallet.buttonConfirm).toBeDisabled();
+ await wallet.inputName.clear();
+ await expect(wallet.errorMessageRenameAccount).toBeHidden();
+ await expect(wallet.buttonConfirm).toBeDisabled();
+ await wallet.inputName.fill(`RenameAccount`);
+ await expect(wallet.buttonConfirm).toBeEnabled();
+ await wallet.buttonConfirm.click();
+ await expect(wallet.buttonGenerateAccount).toBeVisible();
+ await expect(wallet.labelAccountName).toHaveText('RenameAccount');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ });
+
+ test('Reset account name', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.labelAccountName.click();
+ await expect(page.url()).toContain('account-list');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ await wallet.buttonAccountOptions.click();
+ await expect(wallet.buttonRenameAccount).toBeVisible();
+ await wallet.buttonRenameAccount.click();
+ await expect(wallet.buttonResetAccountName).toBeVisible();
+ await wallet.inputName.fill(`RenameAccount`);
+ await expect(wallet.buttonConfirm).toBeEnabled();
+ await wallet.buttonConfirm.click();
+ await expect(wallet.buttonGenerateAccount).toBeVisible();
+ await expect(wallet.labelAccountName).toHaveText('RenameAccount');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ await wallet.buttonAccountOptions.click();
+ await expect(wallet.buttonRenameAccount).toBeVisible();
+ await wallet.buttonRenameAccount.click();
+ await expect(wallet.buttonResetAccountName).toBeVisible();
+ await wallet.buttonResetAccountName.click();
+ await expect(wallet.buttonGenerateAccount).toBeVisible();
+ await expect(wallet.labelAccountName).toHaveText('Account 1');
+ });
+
+ test('Generate new account', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.labelAccountName.click();
+ await expect(page.url()).toContain('account-list');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ await wallet.buttonGenerateAccount.click();
+ await expect(wallet.labelAccountName).toHaveCount(2);
+ await expect(wallet.buttonAccountOptions).toHaveCount(2);
+ await expect(wallet.accountBalance).toHaveCount(2);
+ const balanceText = await wallet.getBalanceOfAllAccounts();
+ await expect(balanceText).toBe(0);
+ });
+
+ test('Switch to another account and switch back', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await expect(wallet.labelAccountName).toHaveText('Account 1');
+ await wallet.labelAccountName.click();
+ await expect(page.url()).toContain('account-list');
+ await expect(wallet.labelAccountName).toHaveCount(1);
+ await wallet.buttonGenerateAccount.click();
+ await expect(wallet.labelAccountName).toHaveCount(2);
+ const balanceText = await wallet.getBalanceOfAllAccounts();
+ await expect(balanceText).toBe(0);
+ await wallet.labelAccountName.last().click();
+ await wallet.checkVisualsStartpage();
+ await expect(wallet.labelAccountName).toHaveText('Account 2');
+ await wallet.labelAccountName.click();
+ await wallet.labelAccountName.first().click();
+ await wallet.checkVisualsStartpage();
+ await expect(wallet.labelAccountName).toHaveText('Account 1');
+ });
+});
diff --git a/tests/specs/managementToken.spec.ts b/tests/specs/managementToken.spec.ts
new file mode 100644
index 000000000..2b0ae97d2
--- /dev/null
+++ b/tests/specs/managementToken.spec.ts
@@ -0,0 +1,257 @@
+import { expect, test } from '../fixtures/base';
+import Onboarding from '../pages/onboarding';
+import Wallet from '../pages/wallet';
+
+const strongPW = Onboarding.generateSecurePasswordCrypto();
+
+test.describe('Token Management', () => {
+ test.beforeEach(async ({ page, extensionId, context }) => {
+ await page.goto(`chrome-extension://${extensionId}/options.html#/landing`);
+ // TODO: this fixes a temporary issue with two tabs at the start see technical debt https://linear.app/xverseapp/issue/ENG-3992/two-tabs-open-instead-of-one-since-version-0323-for-start-extension
+ const pages = await context.pages();
+ if (pages.length === 2) {
+ await pages[1].close(); // pages[1] is the second (newest) page
+ }
+ });
+ test.afterEach(async ({ context }) => {
+ if (context.pages().length > 0) {
+ // Close the context only if there are open pages
+ await context.close();
+ }
+ });
+
+ test('Check token page #smoketest', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.checkVisualsStartpage();
+ await expect(wallet.balance).toHaveText('$0.00');
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await expect(wallet.buttonBack).toBeVisible();
+ await expect(wallet.buttonSip10).toBeVisible();
+ await expect(wallet.buttonBRC20).toBeVisible();
+ await expect(wallet.buttonRunes).toBeVisible();
+ await expect(wallet.headingTokens).toBeVisible();
+ // Check tokens
+ await expect(wallet.labelCoinTitle).toHaveCount(16);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(15);
+ await expect(wallet.checkboxTokenActive).toHaveCount(1);
+ await expect(wallet.checkboxToken).toHaveCount(16);
+ await wallet.buttonBRC20.click();
+ await expect(wallet.labelCoinTitle).toHaveCount(9);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(9);
+ await expect(wallet.checkboxTokenActive).toHaveCount(0);
+ await expect(wallet.checkboxToken).toHaveCount(9);
+ await wallet.buttonRunes.click();
+ await expect(wallet.labelCoinTitle).toHaveCount(0);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(0);
+ await expect(wallet.checkboxTokenActive).toHaveCount(0);
+ await expect(wallet.checkboxToken).toHaveCount(0);
+ });
+
+ test('Enable and disable some BRC-20 token', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+
+ await test.step('Enable a random token', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await await expect(wallet.balance).toHaveText('$0.00');
+ let balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await wallet.buttonBRC20.click();
+ const tokenName = await wallet.enableARandomToken();
+ await expect(wallet.checkboxTokenActive).toHaveCount(1);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(8);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName, { exact: true })).toBeVisible();
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+
+ await test.step('Enable some more token', async () => {
+ await wallet.manageTokenButton.click();
+ await wallet.buttonBRC20.click();
+ const tokenName1 = await wallet.enableARandomToken();
+ const tokenName2 = await wallet.enableARandomToken();
+ const tokenName3 = await wallet.enableARandomToken();
+ await expect(wallet.checkboxTokenActive).toHaveCount(4);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(5);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName1, { exact: true })).toBeVisible();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName2, { exact: true })).toBeVisible();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName3, { exact: true })).toBeVisible();
+ });
+
+ await test.step('Disable a random token', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await wallet.buttonBRC20.click();
+ const tokenName = await wallet.disableARandomToken();
+ await expect(wallet.checkboxTokenActive).toHaveCount(3);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(6);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName, { exact: true })).toBeHidden();
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ const balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+ });
+
+ test('Enable and disable some SIP-10 token', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+
+ await test.step('Enable a random token', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ let balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ const tokenName = await wallet.enableARandomToken();
+ await expect(wallet.checkboxTokenActive).toHaveCount(2);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(14);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName, { exact: true })).toBeVisible();
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+
+ await test.step('Enable some more token', async () => {
+ await wallet.manageTokenButton.click();
+ const tokenName1 = await wallet.enableARandomToken();
+ const tokenName2 = await wallet.enableARandomToken();
+ const tokenName3 = await wallet.enableARandomToken();
+ await expect(wallet.checkboxTokenActive).toHaveCount(5);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(11);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName1, { exact: true })).toBeVisible();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName2, { exact: true })).toBeVisible();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName3, { exact: true })).toBeVisible();
+ });
+
+ await test.step('Disable a random token', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ const tokenName = await wallet.disableARandomToken();
+ await expect(wallet.checkboxTokenActive).toHaveCount(4);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(12);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle.getByText(tokenName, { exact: true })).toBeHidden();
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ const balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+ });
+
+ test('Enable and disable all SIP-10 token', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+
+ await test.step('Enable a all tokens', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ let balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ await expect(wallet.labelTokenSubtitle).toHaveCount(2);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await expect(wallet.checkboxToken).toHaveCount(16);
+ await wallet.enableAllTokens();
+ await expect(wallet.checkboxTokenActive).toHaveCount(16);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(0);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle).toHaveCount(17);
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+
+ await test.step('Disable all tokens', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await wallet.disableAllTokens();
+ await expect(wallet.checkboxTokenActive).toHaveCount(0);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(16);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle).toHaveCount(1);
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ const balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+ });
+ test('Enable and disable all BRC-20 token', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+
+ await test.step('Enable a all tokens', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ let balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ await expect(wallet.labelTokenSubtitle).toHaveCount(2);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await wallet.buttonBRC20.click();
+ await expect(wallet.checkboxToken).toHaveCount(9);
+ await wallet.enableAllTokens();
+ await expect(wallet.checkboxTokenActive).toHaveCount(9);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(0);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle).toHaveCount(11);
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+
+ await test.step('Disable all tokens', async () => {
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await wallet.manageTokenButton.click();
+ await expect(page.url()).toContain('manage-tokens');
+ await wallet.buttonBRC20.click();
+ await wallet.disableAllTokens();
+ await expect(wallet.checkboxTokenActive).toHaveCount(0);
+ await expect(wallet.checkboxTokenInactive).toHaveCount(9);
+ await wallet.buttonBack.click();
+ await expect(wallet.labelTokenSubtitle).toHaveCount(2);
+ // Check balances
+ await expect(wallet.balance).toBeVisible();
+ await expect(wallet.balance).toHaveText('$0.00');
+ const balanceText = await wallet.getBalanceOfAllTokens();
+ await expect(balanceText).toBe(0);
+ });
+ });
+});
diff --git a/tests/specs/onboarding.spec.ts b/tests/specs/onboarding.spec.ts
new file mode 100644
index 000000000..55f8ac5f8
--- /dev/null
+++ b/tests/specs/onboarding.spec.ts
@@ -0,0 +1,208 @@
+import * as bip39 from 'bip39';
+import { expect, test } from '../fixtures/base';
+import { passwordTestCases } from '../fixtures/passwordTestData';
+import Onboarding from '../pages/onboarding';
+import Wallet from '../pages/wallet';
+
+const strongPW = Onboarding.generateSecurePasswordCrypto();
+
+test.describe('onboarding flow', () => {
+ test.beforeEach(async ({ page, extensionId, context }) => {
+ await page.goto(`chrome-extension://${extensionId}/options.html#/landing`);
+ // TODO: this fixes a temporary issue with two tabs at the start see technical debt https://linear.app/xverseapp/issue/ENG-3992/two-tabs-open-instead-of-one-since-version-0323-for-start-extension
+ const pages = await context.pages();
+ if (pages.length === 2) {
+ await pages[1].close(); // pages[1] is the second (newest) page
+ }
+ });
+ test.afterEach(async ({ context }) => {
+ if (context.pages().length > 0) {
+ // Close the context only if there are open pages
+ await context.close();
+ }
+ });
+
+ test('visual check legal page', async ({ page, context, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ // Skip Landing and go directly to legal via URL
+ await page.goto(`chrome-extension://${extensionId}/options.html#/legal`);
+ await onboardingpage.checkLegalPage(context);
+ });
+
+ // Visual check of the first page for backup
+ test('visual check backup page main #smoketest', async ({ page }) => {
+ const onboardingpage = new Onboarding(page);
+ await onboardingpage.navigateToBackupPage();
+ await onboardingpage.checkBackupPage();
+ });
+
+ // Visual check of the first page for password creation
+ test('skip backup and visual check password page', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ // Skip landing and go directly to create password via URL
+ await page.goto(`chrome-extension://${extensionId}/options.html#/create-password`);
+ await expect(page.url()).toContain('create-password');
+ await onboardingpage.checkPasswordPage();
+ await onboardingpage.buttonBack.click();
+ await expect(page.url()).toContain('backup');
+ });
+
+ // No Wallet is created in this step as we only check the display of the error messages and that you can't create a wallet if passwords don't align
+ test('Skip backup and check password error messages #smoketest', async ({ page }) => {
+ const onboardingpage = new Onboarding(page);
+ await onboardingpage.navigateToBackupPage();
+ await onboardingpage.buttonBackupLater.click();
+ await expect(page.url()).toContain('create-password');
+
+ // Check error message, security label change, and status of continue button
+ await passwordTestCases.reduce(async (previousPromise, testCase) => {
+ await previousPromise;
+ return onboardingpage.testPasswordInput(testCase);
+ }, Promise.resolve());
+
+ await onboardingpage.inputPassword.fill(strongPW);
+ await onboardingpage.buttonContinue.click();
+ // check Confirm header
+ await expect(page.locator('h1')).toHaveText(/confirm/i);
+ // Enter wrong password to check error messages
+ await expect(onboardingpage.buttonContinue).toBeDisabled();
+ await onboardingpage.inputPassword.fill(`${strongPW}123`);
+ await expect(onboardingpage.buttonContinue).toBeEnabled();
+ await onboardingpage.buttonContinue.click();
+ await expect(onboardingpage.errorMessage2).toBeVisible();
+ // multiple times clicking on continue to check that the user stays on the page and can't continue even of clicked multiple times
+ await Onboarding.multipleClickCheck(onboardingpage.buttonContinue);
+ await expect(onboardingpage.errorMessage2).toBeVisible();
+ await onboardingpage.buttonBack.click();
+ await expect(onboardingpage.inputPassword).toHaveValue(/.+/);
+ await onboardingpage.buttonContinue.click();
+ await expect(onboardingpage.inputPassword).toHaveValue(/.+/);
+ await expect(onboardingpage.errorMessage2).toBeVisible();
+ });
+
+ test('Restore wallet error message check for false 12 word seed phrase #smoketest', async ({
+ page,
+ }) => {
+ const onboardingpage = new Onboarding(page);
+ await onboardingpage.navigateToRestorePage();
+
+ await onboardingpage.checkRestoreWalletSeedPhrasePage();
+
+ // get 12 words from bip39
+ const mnemonic = bip39.generateMnemonic();
+ const wordsArray = mnemonic.split(' '); // Split the mnemonic by spaces
+
+ // We only input 11 word to cause the error message
+ for (let i = 0; i < wordsArray.length - 1; i++) {
+ await onboardingpage.inputWord(i).fill(wordsArray[i]);
+ }
+
+ await expect(onboardingpage.buttonContinue).toBeEnabled();
+ await onboardingpage.buttonContinue.click();
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(12);
+ await expect(onboardingpage.buttonContinue).toBeEnabled();
+ await expect(onboardingpage.errorMessageSeedPhrase).toBeVisible();
+
+ // multiple times clicking on continue to check that the user stays on the page and can't continue even of clicked multiple times
+ await Onboarding.multipleClickCheck(onboardingpage.buttonContinue);
+ await expect(page.url()).toContain('restoreWallet');
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(12);
+ await expect(onboardingpage.errorMessageSeedPhrase).toBeVisible();
+
+ // empty all fields and check if continue button is disabled
+ for (let i = 0; i < 12; i++) {
+ await onboardingpage.inputWord(i).clear();
+ }
+
+ await expect(onboardingpage.buttonContinue).toBeDisabled();
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(12);
+ await expect(onboardingpage.errorMessageSeedPhrase).toBeHidden();
+ });
+
+ test('Restore wallet Error Message check for false 24 word seed phrase', async ({ page }) => {
+ const onboardingpage = new Onboarding(page);
+ await onboardingpage.navigateToRestorePage();
+
+ await onboardingpage.checkRestoreWalletSeedPhrasePage();
+ await onboardingpage.button24SeedPhrase.click();
+
+ // All input fields should now be visible and enabled
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(0);
+ await expect(onboardingpage.inputSeedPhraseWord).toHaveCount(24);
+
+ // get 24 words from bip39
+ const mnemonic = bip39.generateMnemonic(256);
+ const wordsArray = mnemonic.split(' '); // Split the mnemonic by spaces
+
+ for (let i = 0; i < wordsArray.length - 1; i++) {
+ await onboardingpage.inputWord(i).fill(wordsArray[i]);
+ }
+ await expect(onboardingpage.buttonContinue).toBeEnabled();
+ await onboardingpage.buttonContinue.click();
+
+ // As the seed phrase is not complete an error should be shown and the continue button is still enabled
+ await expect(onboardingpage.buttonContinue).toBeEnabled();
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(0);
+ await expect(onboardingpage.errorMessageSeedPhrase).toBeVisible();
+
+ // multiple times clicking on continue to check that the user stays on the page and can't continue even of clicked multiple times
+ await Onboarding.multipleClickCheck(onboardingpage.buttonContinue);
+ await expect(page.url()).toContain('restoreWallet');
+
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(0);
+ await expect(onboardingpage.errorMessageSeedPhrase).toBeVisible();
+
+ // empty all fields and check if continue button is disabled
+ for (let i = 0; i < 24; i++) {
+ await onboardingpage.inputWord(i).clear();
+ }
+ await expect(onboardingpage.buttonContinue).toBeDisabled();
+ await expect(onboardingpage.errorMessageSeedPhrase).toBeHidden();
+ });
+
+ test('Restore wallet check switch 12 to 24 seed phrase', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+
+ // Skip Landing and go directly to restore wallet via URL
+ await page.goto(`chrome-extension://${extensionId}/options.html#/restoreWallet`);
+
+ await onboardingpage.checkRestoreWalletSeedPhrasePage();
+
+ await onboardingpage.button24SeedPhrase.click();
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(0);
+ await expect(onboardingpage.inputSeedPhraseWord).toHaveCount(24);
+ await expect(onboardingpage.buttonContinue).toBeDisabled();
+ await expect(onboardingpage.button12SeedPhrase).toBeVisible();
+
+ await onboardingpage.button12SeedPhrase.click();
+ await expect(onboardingpage.inputSeedPhraseWordDisabled).toHaveCount(12);
+ await expect(onboardingpage.inputSeedPhraseWord).toHaveCount(24);
+ await expect(onboardingpage.buttonContinue).toBeDisabled();
+ await expect(onboardingpage.button24SeedPhrase).toBeVisible();
+ });
+
+ test('Lock and login #smoketest', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html`);
+ await expect(wallet.buttonMenu).toBeVisible();
+ await wallet.buttonMenu.click();
+ await expect(wallet.buttonLock).toBeVisible();
+ await wallet.buttonLock.click();
+ await expect(onboardingpage.inputPassword).toBeVisible();
+ await onboardingpage.inputPassword.fill(strongPW);
+ await onboardingpage.buttonUnlock.click();
+ await wallet.checkVisualsStartpage();
+ });
+
+ test('switch to testnet and back to mainnet', async ({ page, extensionId }) => {
+ const onboardingpage = new Onboarding(page);
+ const wallet = new Wallet(page);
+ await onboardingpage.createWalletSkipBackup(strongPW);
+ await page.goto(`chrome-extension://${extensionId}/popup.html#/settings`);
+
+ await wallet.switchtoTestnetNetwork();
+ await wallet.switchtoMainnetNetwork();
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index c79850888..d94c7348d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -31,5 +31,5 @@
"@ui-components/*": ["app/ui-components/*"]
}
},
- "include": ["src", "styled.d.ts"]
+ "include": ["src", "tests", "styled.d.ts", "playwright.config.ts"]
}
diff --git a/webpack/makeConfig.js b/webpack/makeConfig.js
new file mode 100644
index 000000000..e693658b6
--- /dev/null
+++ b/webpack/makeConfig.js
@@ -0,0 +1,204 @@
+var webpack = require('webpack'),
+ path = require('path'),
+ env = require('./utils/env'),
+ CopyWebpackPlugin = require('copy-webpack-plugin'),
+ HtmlWebpackPlugin = require('html-webpack-plugin'),
+ TerserPlugin = require('terser-webpack-plugin');
+var { CleanWebpackPlugin } = require('clean-webpack-plugin');
+const ReactRefreshTypeScript = require('react-refresh-typescript');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
+const Dotenv = require('dotenv-webpack');
+const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
+const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
+const styledComponentsTransformer = createStyledComponentsTransformer();
+const keysTransformer = require('ts-transformer-keys/transformer').default;
+
+const aliases = {
+ // alias stacks.js packages to their esm (default prefers /dist/polyfill)
+ '@stacks/transactions': '@stacks/transactions/dist/esm',
+ '@secretkeylabs/xverse-core': '@secretkeylabs/xverse-core/dist',
+};
+
+const ASSET_PATH = process.env.ASSET_PATH || '/';
+const SRC_ROOT_PATH = path.join(__dirname, '../', 'src');
+const DEFAULT_BUILD_ROOT_PATH = path.join(__dirname, '../', 'build');
+
+var fileExtensions = ['jpg', 'jpeg', 'png', 'gif', 'eot', 'otf', 'svg', 'ttf', 'woff', 'woff2'];
+
+function makeConfig(opts) {
+ const userBuildRootPath = opts?.buildRootPath;
+ const buildRootPath = userBuildRootPath ?? DEFAULT_BUILD_ROOT_PATH;
+ var options = {
+ mode: env.NODE_ENV || 'development',
+
+ entry: {
+ background: path.join(SRC_ROOT_PATH, 'background', 'background.ts'),
+ inpage: path.join(SRC_ROOT_PATH, 'inpage', 'index.ts'),
+ 'content-script': path.join(SRC_ROOT_PATH, 'content-scripts', 'content-script.ts'),
+ options: path.join(SRC_ROOT_PATH, 'pages', 'Options', 'index.tsx'),
+ popup: path.join(SRC_ROOT_PATH, 'pages', 'Popup', 'index.tsx'),
+ },
+ output: {
+ filename: '[name].js',
+ path: buildRootPath,
+ clean: true,
+ publicPath: ASSET_PATH,
+ },
+ module: {
+ noParse: /\.wasm$/,
+ rules: [
+ {
+ test: /\.(css)$/,
+ use: [
+ {
+ loader: 'style-loader',
+ },
+ {
+ loader: 'css-loader',
+ },
+ ],
+ },
+ {
+ test: new RegExp('.(' + fileExtensions.join('|') + ')$'),
+ type: 'asset/resource',
+ exclude: /node_modules/,
+ },
+ {
+ test: /\.html$/,
+ loader: 'html-loader',
+ exclude: /node_modules/,
+ },
+ {
+ test: /\.[jt]sx?$/,
+ exclude: /node_modules/,
+ use: [
+ {
+ loader: 'ts-loader',
+ options: {
+ getCustomTransformers: (program) => ({
+ before:
+ env.NODE_ENV === 'development'
+ ? [
+ ReactRefreshTypeScript(),
+ styledComponentsTransformer,
+ keysTransformer(program),
+ ]
+ : [keysTransformer(program)],
+ }),
+ transpileOnly: false,
+ },
+ },
+ ],
+ },
+ {
+ test: /\.wasm$/,
+ // Tells WebPack that this module should be included as
+ // base64-encoded binary file and not as code
+ loader: 'base64-loader',
+ // Disables WebPack's opinion where WebAssembly should be,
+ // makes it think that it's not WebAssembly
+ //
+ // Error: WebAssembly module is included in initial chunk.
+ type: 'javascript/auto',
+ },
+ ],
+ },
+ resolve: {
+ plugins: [
+ new TsconfigPathsPlugin({
+ configFile: path.join(__dirname, '../', 'tsconfig.json'),
+ }),
+ ],
+ extensions: fileExtensions
+ .map((extension) => '.' + extension)
+ .concat(['.js', '.jsx', '.ts', '.tsx', '.css']),
+ alias: aliases,
+ fallback: {
+ stream: require.resolve('stream-browserify'),
+ crypto: require.resolve('crypto-browserify'),
+ fs: false,
+ },
+ },
+ plugins: [
+ new ForkTsCheckerWebpackPlugin(),
+ new Dotenv({ safe: true, systemvars: true }),
+ new CleanWebpackPlugin({ verbose: false }),
+ new webpack.ProgressPlugin(),
+ // expose and write the allowed env vars on the compiled bundle
+ new webpack.EnvironmentPlugin(['NODE_ENV']),
+ new CopyWebpackPlugin({
+ patterns: [
+ {
+ from: 'src/manifest.json',
+ to: buildRootPath,
+ force: true,
+ transform: function (content, path) {
+ // generates the manifest file using the package.json informations
+ return Buffer.from(
+ JSON.stringify({
+ description: process.env.npm_package_description,
+ version: process.env.npm_package_version,
+ ...JSON.parse(content.toString()),
+ }),
+ );
+ },
+ },
+ ],
+ }),
+ new CopyWebpackPlugin({
+ patterns: [
+ {
+ from: path.join(SRC_ROOT_PATH, 'assets/img/xverse_icon.png'),
+ to: buildRootPath,
+ },
+ ],
+ }),
+ new CopyWebpackPlugin({
+ patterns: [
+ {
+ from: 'node_modules/webextension-polyfill/dist/browser-polyfill.js',
+ },
+ ],
+ }),
+ new HtmlWebpackPlugin({
+ template: path.join(SRC_ROOT_PATH, 'pages', 'Options', 'index.html'),
+ filename: 'options.html',
+ chunks: ['options'],
+ cache: false,
+ }),
+ new HtmlWebpackPlugin({
+ template: path.join(SRC_ROOT_PATH, 'pages', 'Popup', 'index.html'),
+ filename: 'popup.html',
+ chunks: ['popup'],
+ cache: false,
+ }),
+ new webpack.ProvidePlugin({
+ process: 'process/browser.js',
+ Buffer: ['buffer', 'Buffer'],
+ }),
+ new webpack.DefinePlugin({
+ VERSION: JSON.stringify(require('../package.json').version),
+ }),
+ ],
+
+ infrastructureLogging: {
+ level: 'info',
+ },
+ };
+ if (env.NODE_ENV === 'development') {
+ options.devtool = 'cheap-module-source-map';
+ } else {
+ options.optimization = {
+ minimize: true,
+ minimizer: [
+ new TerserPlugin({
+ extractComments: false,
+ }),
+ ],
+ };
+ }
+
+ return options;
+}
+
+exports.makeConfig = makeConfig;
diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js
index b6b7210f4..22d03ed64 100644
--- a/webpack/webpack.config.js
+++ b/webpack/webpack.config.js
@@ -1,190 +1,3 @@
-var webpack = require('webpack'),
- path = require('path'),
- env = require('./utils/env'),
- CopyWebpackPlugin = require('copy-webpack-plugin'),
- HtmlWebpackPlugin = require('html-webpack-plugin'),
- TerserPlugin = require('terser-webpack-plugin');
-var { CleanWebpackPlugin } = require('clean-webpack-plugin');
-const ReactRefreshTypeScript = require('react-refresh-typescript');
-const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
-const Dotenv = require('dotenv-webpack');
-const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
-const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
-const styledComponentsTransformer = createStyledComponentsTransformer();
+const { makeConfig } = require('./makeConfig');
-const aliases = {
- // alias stacks.js packages to their esm (default prefers /dist/polyfill)
- '@stacks/transactions': '@stacks/transactions/dist/esm',
- '@secretkeylabs/xverse-core': '@secretkeylabs/xverse-core/dist',
-};
-const ASSET_PATH = process.env.ASSET_PATH || '/';
-const SRC_ROOT_PATH = path.join(__dirname, '../', 'src');
-const BUILD_ROOT_PATH = path.join(__dirname, '../', 'build');
-
-var fileExtensions = ['jpg', 'jpeg', 'png', 'gif', 'eot', 'otf', 'svg', 'ttf', 'woff', 'woff2'];
-var options = {
- mode: env.NODE_ENV || 'development',
-
- entry: {
- background: path.join(SRC_ROOT_PATH, 'background', 'background.ts'),
- inpage: path.join(SRC_ROOT_PATH, 'inpage', 'index.ts'),
- 'content-script': path.join(SRC_ROOT_PATH, 'content-scripts', 'content-script.ts'),
- options: path.join(SRC_ROOT_PATH, 'pages', 'Options', 'index.tsx'),
- popup: path.join(SRC_ROOT_PATH, 'pages', 'Popup', 'index.tsx'),
- },
- output: {
- filename: '[name].js',
- path: BUILD_ROOT_PATH,
- clean: true,
- publicPath: ASSET_PATH,
- },
- module: {
- noParse: /\.wasm$/,
- rules: [
- {
- test: /\.(css)$/,
- use: [
- {
- loader: 'style-loader',
- },
- {
- loader: 'css-loader',
- },
- ],
- },
- {
- test: new RegExp('.(' + fileExtensions.join('|') + ')$'),
- type: 'asset/resource',
- exclude: /node_modules/,
- },
- {
- test: /\.html$/,
- loader: 'html-loader',
- exclude: /node_modules/,
- },
- {
- test: /\.[jt]sx?$/,
- exclude: /node_modules/,
- use: [
- {
- loader: 'ts-loader',
- options: {
- getCustomTransformers: () => ({
- before:
- env.NODE_ENV === 'development'
- ? [ReactRefreshTypeScript(), styledComponentsTransformer]
- : [],
- }),
- transpileOnly: false,
- },
- },
- ],
- },
- {
- test: /\.wasm$/,
- // Tells WebPack that this module should be included as
- // base64-encoded binary file and not as code
- loader: 'base64-loader',
- // Disables WebPack's opinion where WebAssembly should be,
- // makes it think that it's not WebAssembly
- //
- // Error: WebAssembly module is included in initial chunk.
- type: 'javascript/auto',
- },
- ],
- },
- resolve: {
- plugins: [
- new TsconfigPathsPlugin({
- configFile: path.join(__dirname, '../', 'tsconfig.json'),
- }),
- ],
- extensions: fileExtensions
- .map((extension) => '.' + extension)
- .concat(['.js', '.jsx', '.ts', '.tsx', '.css']),
- alias: aliases,
- fallback: {
- stream: require.resolve('stream-browserify'),
- crypto: require.resolve('crypto-browserify'),
- fs: false,
- },
- },
- plugins: [
- new ForkTsCheckerWebpackPlugin(),
- new Dotenv({ safe: true, systemvars: true }),
- new CleanWebpackPlugin({ verbose: false }),
- new webpack.ProgressPlugin(),
- // expose and write the allowed env vars on the compiled bundle
- new webpack.EnvironmentPlugin(['NODE_ENV']),
- new CopyWebpackPlugin({
- patterns: [
- {
- from: 'src/manifest.json',
- to: BUILD_ROOT_PATH,
- force: true,
- transform: function (content, path) {
- // generates the manifest file using the package.json informations
- return Buffer.from(
- JSON.stringify({
- description: process.env.npm_package_description,
- version: process.env.npm_package_version,
- ...JSON.parse(content.toString()),
- }),
- );
- },
- },
- ],
- }),
- new CopyWebpackPlugin({
- patterns: [
- {
- from: path.join(SRC_ROOT_PATH, 'assets/img/xverse_icon.png'),
- to: BUILD_ROOT_PATH,
- },
- ],
- }),
- new CopyWebpackPlugin({
- patterns: [
- {
- from: 'node_modules/webextension-polyfill/dist/browser-polyfill.js',
- },
- ],
- }),
- new HtmlWebpackPlugin({
- template: path.join(SRC_ROOT_PATH, 'pages', 'Options', 'index.html'),
- filename: 'options.html',
- chunks: ['options'],
- cache: false,
- }),
- new HtmlWebpackPlugin({
- template: path.join(SRC_ROOT_PATH, 'pages', 'Popup', 'index.html'),
- filename: 'popup.html',
- chunks: ['popup'],
- cache: false,
- }),
- new webpack.ProvidePlugin({
- process: 'process/browser.js',
- Buffer: ['buffer', 'Buffer'],
- }),
- new webpack.DefinePlugin({
- VERSION: JSON.stringify(require('../package.json').version),
- }),
- ],
-
- infrastructureLogging: {
- level: 'info',
- },
-};
-if (env.NODE_ENV === 'development') {
- options.devtool = 'cheap-module-source-map';
-} else {
- options.optimization = {
- minimize: true,
- minimizer: [
- new TerserPlugin({
- extractComments: false,
- }),
- ],
- };
-}
-module.exports = options;
+module.exports = makeConfig();