diff --git a/src/app/components/fees-row/components/custom-fee-field.tsx b/src/app/components/fees-row/components/custom-fee-field.tsx index 2572d8c95b8..a5a8f4a7d62 100644 --- a/src/app/components/fees-row/components/custom-fee-field.tsx +++ b/src/app/components/fees-row/components/custom-fee-field.tsx @@ -15,9 +15,11 @@ interface CustomFeeFieldProps extends StackProps { feeCurrencySymbol: CryptoCurrencies; lowFeeEstimate: StacksFeeEstimate; setFieldWarning(value: string): void; + disableFeeSelection?: boolean; } export function CustomFeeField(props: CustomFeeFieldProps) { - const { feeCurrencySymbol, lowFeeEstimate, setFieldWarning, ...rest } = props; + const { feeCurrencySymbol, lowFeeEstimate, setFieldWarning, disableFeeSelection, ...rest } = + props; const [field, meta, helpers] = useField('fee'); const checkFieldWarning = useCallback( @@ -52,6 +54,7 @@ export function CustomFeeField(props: CustomFeeFieldProps) { display="block" height="32px" name="fee" + isDisabled={disableFeeSelection} onChange={(evt: FormEvent) => { helpers.setValue(evt.currentTarget.value); // Separating warning check from field validations diff --git a/src/app/components/fees-row/components/fee-estimate-item.tsx b/src/app/components/fees-row/components/fee-estimate-item.tsx index ec7b17a8afa..f5f60e18eae 100644 --- a/src/app/components/fees-row/components/fee-estimate-item.tsx +++ b/src/app/components/fees-row/components/fee-estimate-item.tsx @@ -11,9 +11,10 @@ interface FeeEstimateItemProps { isVisible?: boolean; onSelectItem(index: number): void; selectedItem: number; + disableFeeSelection?: boolean; } export function FeeEstimateItem(props: FeeEstimateItemProps) { - const { index, isVisible, onSelectItem, selectedItem } = props; + const { index, isVisible, onSelectItem, selectedItem, disableFeeSelection } = props; const selectedIcon = useMemo(() => { const isSelected = index === selectedItem; @@ -29,13 +30,13 @@ export function FeeEstimateItem(props: FeeEstimateItemProps) { isInline mb="0px !important" minWidth="100px" - onClick={() => onSelectItem(index)} + onClick={() => !disableFeeSelection && onSelectItem(index)} p="tight" > {labels[index]} - {isVisible ? selectedIcon : } + {!disableFeeSelection && (isVisible ? selectedIcon : )} ); } diff --git a/src/app/components/fees-row/components/fee-estimate-select.layout.tsx b/src/app/components/fees-row/components/fee-estimate-select.layout.tsx index 0e2988812b6..d0c5fa78e0f 100644 --- a/src/app/components/fees-row/components/fee-estimate-select.layout.tsx +++ b/src/app/components/fees-row/components/fee-estimate-select.layout.tsx @@ -20,17 +20,19 @@ interface FeeEstimateSelectLayoutProps { isVisible: boolean; onSetIsSelectVisible(value: boolean): void; selectedItem: number; + disableFeeSelection?: boolean; } export function FeeEstimateSelectLayout(props: FeeEstimateSelectLayoutProps) { - const { children, isVisible, onSetIsSelectVisible, selectedItem } = props; + const { children, isVisible, onSetIsSelectVisible, selectedItem, disableFeeSelection } = props; const ref = useRef(null); useOnClickOutside(ref, () => onSetIsSelectVisible(false)); return ( <> - + onSetIsSelectVisible(true)} selectedItem={FeeTypes.Middle} diff --git a/src/app/components/fees-row/components/fee-estimate-select.tsx b/src/app/components/fees-row/components/fee-estimate-select.tsx index 5ff6ada1414..f2503471209 100644 --- a/src/app/components/fees-row/components/fee-estimate-select.tsx +++ b/src/app/components/fees-row/components/fee-estimate-select.tsx @@ -11,13 +11,22 @@ interface FeeEstimateSelectProps { onSetIsSelectVisible(value: boolean): void; selectedItem: number; allowCustom: boolean; + disableFeeSelection?: boolean; } export function FeeEstimateSelect(props: FeeEstimateSelectProps) { - const { isVisible, estimate, onSelectItem, onSetIsSelectVisible, selectedItem, allowCustom } = - props; + const { + isVisible, + estimate, + onSelectItem, + onSetIsSelectVisible, + selectedItem, + allowCustom, + disableFeeSelection, + } = props; return ( { - if (hasFeeEstimates && !feeField.value && !isCustom) { + if (props.defaultFeeValue) { + feeHelper.setValue( + convertAmountToBaseUnit( + new BigNumber(Number(props.defaultFeeValue)), + STX_DECIMALS + ).toString() + ); + feeTypeHelper.setValue(FeeTypes[FeeTypes.Custom]); + } + }, [feeHelper, props.defaultFeeValue, feeTypeHelper]); + + useEffect(() => { + if (!props.defaultFeeValue && hasFeeEstimates && !feeField.value && !isCustom) { feeHelper.setValue(convertAmountToBaseUnit(fees.estimates[FeeTypes.Middle].fee).toString()); feeTypeHelper.setValue(FeeTypes[FeeTypes.Middle]); } @@ -54,6 +69,7 @@ export function FeesRow(props: FeeRowProps): React.JSX.Element { feeHelper.setValue(0); } }, [ + props.defaultFeeValue, feeField.value, feeHelper, feeTypeHelper, @@ -66,13 +82,18 @@ export function FeesRow(props: FeeRowProps): React.JSX.Element { const handleSelectFeeEstimateOrCustomField = useCallback( (index: number) => { feeTypeHelper.setValue(FeeTypes[index]); - if (index === FeeTypes.Custom) feeHelper.setValue(''); + if (index === FeeTypes.Custom) + feeHelper.setValue( + props.defaultFeeValue + ? convertAmountToBaseUnit(new BigNumber(Number(props.defaultFeeValue)), STX_DECIMALS) + : '' + ); else fees && feeHelper.setValue(convertAmountToBaseUnit(fees.estimates[index].fee).toString()); setFieldWarning(''); setIsSelectVisible(false); }, - [feeTypeHelper, feeHelper, fees] + [feeTypeHelper, feeHelper, fees, props.defaultFeeValue] ); if (!hasFeeEstimates) return ; @@ -83,6 +104,7 @@ export function FeesRow(props: FeeRowProps): React.JSX.Element { feeField={ isCustom ? ( setFieldWarning(value)} @@ -101,6 +123,7 @@ export function FeesRow(props: FeeRowProps): React.JSX.Element { isSponsored={isSponsored} selectInput={ (); @@ -21,5 +18,5 @@ export function NonceSetter({ children }: NonceSetterProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [nextNonce?.nonce]); - return <>{children}; + return <>; } diff --git a/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx b/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx index 845934bcd39..938c2f98910 100644 --- a/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx +++ b/src/app/features/edit-nonce-drawer/edit-nonce-drawer.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useFormikContext } from 'formik'; import { Stack, styled } from 'leather-styles/jsx'; @@ -33,10 +33,11 @@ export function EditNonceDrawer() { const [loadedNextNonce, setLoadedNextNonce] = useState(); const navigate = useNavigate(); + const { search } = useLocation(); useOnMount(() => setLoadedNextNonce(values.nonce)); - const onGoBack = useCallback(() => navigate('..'), [navigate]); + const onGoBack = useCallback(() => navigate('..' + search), [navigate, search]); const onBlur = useCallback(() => validateField('nonce'), [validateField]); diff --git a/src/app/pages/transaction-request/components/attachment-row.tsx b/src/app/features/stacks-transaction-request/attachment-row.tsx similarity index 100% rename from src/app/pages/transaction-request/components/attachment-row.tsx rename to src/app/features/stacks-transaction-request/attachment-row.tsx diff --git a/src/app/pages/transaction-request/components/contract-call-details/contract-call-details.tsx b/src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx similarity index 90% rename from src/app/pages/transaction-request/components/contract-call-details/contract-call-details.tsx rename to src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx index c44e5346191..b71e4d8b44b 100644 --- a/src/app/pages/transaction-request/components/contract-call-details/contract-call-details.tsx +++ b/src/app/features/stacks-transaction-request/contract-call-details/contract-call-details.tsx @@ -6,8 +6,8 @@ import { useExplorerLink } from '@app/common/hooks/use-explorer-link'; import { formatContractId } from '@app/common/utils'; import { Divider } from '@app/components/layout/divider'; import { Title } from '@app/components/typography'; -import { AttachmentRow } from '@app/pages/transaction-request/components/attachment-row'; -import { ContractPreviewLayout } from '@app/pages/transaction-request/components/contract-preview'; +import { AttachmentRow } from '@app/features/stacks-transaction-request/attachment-row'; +import { ContractPreviewLayout } from '@app/features/stacks-transaction-request/contract-preview'; import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; import { FunctionArgumentsList } from './function-arguments-list'; diff --git a/src/app/pages/transaction-request/components/contract-call-details/function-argument-item.tsx b/src/app/features/stacks-transaction-request/contract-call-details/function-argument-item.tsx similarity index 90% rename from src/app/pages/transaction-request/components/contract-call-details/function-argument-item.tsx rename to src/app/features/stacks-transaction-request/contract-call-details/function-argument-item.tsx index 203e95fb395..9c737d2d958 100644 --- a/src/app/pages/transaction-request/components/contract-call-details/function-argument-item.tsx +++ b/src/app/features/stacks-transaction-request/contract-call-details/function-argument-item.tsx @@ -1,6 +1,6 @@ import { cvToString, deserializeCV, getCVTypeString } from '@stacks/transactions'; -import { Row } from '@app/pages/transaction-request/components/row'; +import { Row } from '@app/features/stacks-transaction-request/row'; import { useContractFunction } from '@app/query/stacks/contract/contract.hooks'; interface FunctionArgumentProps { diff --git a/src/app/pages/transaction-request/components/contract-call-details/function-arguments-list.tsx b/src/app/features/stacks-transaction-request/contract-call-details/function-arguments-list.tsx similarity index 100% rename from src/app/pages/transaction-request/components/contract-call-details/function-arguments-list.tsx rename to src/app/features/stacks-transaction-request/contract-call-details/function-arguments-list.tsx diff --git a/src/app/pages/transaction-request/components/contract-deploy-details/contract-deploy-details.tsx b/src/app/features/stacks-transaction-request/contract-deploy-details/contract-deploy-details.tsx similarity index 92% rename from src/app/pages/transaction-request/components/contract-deploy-details/contract-deploy-details.tsx rename to src/app/features/stacks-transaction-request/contract-deploy-details/contract-deploy-details.tsx index 729d00d1695..f05bbeeabb4 100644 --- a/src/app/pages/transaction-request/components/contract-deploy-details/contract-deploy-details.tsx +++ b/src/app/features/stacks-transaction-request/contract-deploy-details/contract-deploy-details.tsx @@ -5,9 +5,9 @@ import { BoxProps, CodeBlock, Stack, color } from '@stacks/ui'; import { Prism } from '@app/common/clarity-prism'; import { Divider } from '@app/components/layout/divider'; import { Caption, Title } from '@app/components/typography'; -import { AttachmentRow } from '@app/pages/transaction-request/components/attachment-row'; -import { ContractPreviewLayout } from '@app/pages/transaction-request/components/contract-preview'; -import { Row } from '@app/pages/transaction-request/components/row'; +import { AttachmentRow } from '@app/features/stacks-transaction-request/attachment-row'; +import { ContractPreviewLayout } from '@app/features/stacks-transaction-request/contract-preview'; +import { Row } from '@app/features/stacks-transaction-request/row'; import { useCurrentAccountStxAddressState, useCurrentStacksAccount, diff --git a/src/app/pages/transaction-request/components/contract-preview.tsx b/src/app/features/stacks-transaction-request/contract-preview.tsx similarity index 100% rename from src/app/pages/transaction-request/components/contract-preview.tsx rename to src/app/features/stacks-transaction-request/contract-preview.tsx diff --git a/src/app/pages/transaction-request/components/fee-form.tsx b/src/app/features/stacks-transaction-request/fee-form.tsx similarity index 72% rename from src/app/pages/transaction-request/components/fee-form.tsx rename to src/app/features/stacks-transaction-request/fee-form.tsx index a32a3c15c0b..d97c22fe1ef 100644 --- a/src/app/pages/transaction-request/components/fee-form.tsx +++ b/src/app/features/stacks-transaction-request/fee-form.tsx @@ -10,9 +10,11 @@ import { useUnsignedPrepareTransactionDetails } from '@app/store/transactions/tr interface FeeFormProps { fees?: Fees; + disableFeeSelection?: boolean; + defaultFeeValue?: number; } -export function FeeForm({ fees }: FeeFormProps) { +export function FeeForm({ fees, disableFeeSelection, defaultFeeValue }: FeeFormProps) { const { values } = useFormikContext(); const transaction = useUnsignedPrepareTransactionDetails(values); @@ -21,7 +23,12 @@ export function FeeForm({ fees }: FeeFormProps) { return ( <> {fees?.estimates.length ? ( - + ) : ( )} diff --git a/src/app/pages/transaction-request/hooks/use-page-title.ts b/src/app/features/stacks-transaction-request/hooks/use-page-title.ts similarity index 100% rename from src/app/pages/transaction-request/hooks/use-page-title.ts rename to src/app/features/stacks-transaction-request/hooks/use-page-title.ts diff --git a/src/app/pages/transaction-request/hooks/use-transaction-error.ts b/src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts similarity index 88% rename from src/app/pages/transaction-request/hooks/use-transaction-error.ts rename to src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts index 325c8d58e29..fc980743c07 100644 --- a/src/app/pages/transaction-request/hooks/use-transaction-error.ts +++ b/src/app/features/stacks-transaction-request/hooks/use-transaction-error.ts @@ -4,14 +4,19 @@ import { ContractCallPayload, TransactionTypes } from '@stacks/connect'; import BigNumber from 'bignumber.js'; import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; +import { initialSearchParams } from '@app/common/initial-search-params'; import { microStxToStx } from '@app/common/money/unit-conversion'; import { validateStacksAddress } from '@app/common/stacks-utils'; -import { TransactionErrorReason } from '@app/pages/transaction-request/components/transaction-error/transaction-error'; +import { TransactionErrorReason } from '@app/features/stacks-transaction-request/transaction-error/transaction-error'; import { useCurrentStacksAccountAnchoredBalances } from '@app/query/stacks/balance/stx-balance.hooks'; import { useContractInterface } from '@app/query/stacks/contract/contract.hooks'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; +function getIsMultisig() { + return initialSearchParams.get('isMultisig') === 'true'; +} + export function useTransactionError() { const transactionRequest = useTransactionRequestState(); const contractInterface = useContractInterface(transactionRequest as ContractCallPayload); @@ -33,7 +38,7 @@ export function useTransactionError() { if ((contractInterface as any)?.isError) return TransactionErrorReason.NoContract; } - if (balances) { + if (balances && !getIsMultisig()) { const zeroBalance = balances?.stx.unlockedStx.amount.toNumber() === 0; if (transactionRequest.txType === TransactionTypes.STXTransfer) { diff --git a/src/app/pages/transaction-request/components/minimal-error-message.tsx b/src/app/features/stacks-transaction-request/minimal-error-message.tsx similarity index 88% rename from src/app/pages/transaction-request/components/minimal-error-message.tsx rename to src/app/features/stacks-transaction-request/minimal-error-message.tsx index 2138f8a1a47..eea14468c39 100644 --- a/src/app/pages/transaction-request/components/minimal-error-message.tsx +++ b/src/app/features/stacks-transaction-request/minimal-error-message.tsx @@ -5,8 +5,8 @@ import { TransactionRequestSelectors } from '@tests/selectors/requests.selectors import { styled } from 'leather-styles/jsx'; import { ErrorIcon } from '@app/components/icons/error-icon'; -import { TransactionErrorReason } from '@app/pages/transaction-request/components/transaction-error/transaction-error'; -import { useTransactionError } from '@app/pages/transaction-request/hooks/use-transaction-error'; +import { useTransactionError } from '@app/features/stacks-transaction-request/hooks/use-transaction-error'; +import { TransactionErrorReason } from '@app/features/stacks-transaction-request/transaction-error/transaction-error'; function MinimalErrorMessageSuspense(props: StackProps) { const error = useTransactionError(); diff --git a/src/app/pages/transaction-request/components/page-top.tsx b/src/app/features/stacks-transaction-request/page-top.tsx similarity index 95% rename from src/app/pages/transaction-request/components/page-top.tsx rename to src/app/features/stacks-transaction-request/page-top.tsx index 336e2cec4b7..d98d45258bf 100644 --- a/src/app/pages/transaction-request/components/page-top.tsx +++ b/src/app/features/stacks-transaction-request/page-top.tsx @@ -8,7 +8,7 @@ import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-s import { addPortSuffix, getUrlHostname } from '@app/common/utils'; import { Favicon } from '@app/components/favicon'; import { Flag } from '@app/components/layout/flag'; -import { usePageTitle } from '@app/pages/transaction-request/hooks/use-page-title'; +import { usePageTitle } from '@app/features/stacks-transaction-request/hooks/use-page-title'; import { useCurrentNetworkState } from '@app/store/networks/networks.hooks'; import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; diff --git a/src/app/pages/transaction-request/components/post-condition-mode-warning.tsx b/src/app/features/stacks-transaction-request/post-condition-mode-warning.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-condition-mode-warning.tsx rename to src/app/features/stacks-transaction-request/post-condition-mode-warning.tsx diff --git a/src/app/pages/transaction-request/components/post-conditions/fungible-post-condition-item.tsx b/src/app/features/stacks-transaction-request/post-conditions/fungible-post-condition-item.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-conditions/fungible-post-condition-item.tsx rename to src/app/features/stacks-transaction-request/post-conditions/fungible-post-condition-item.tsx diff --git a/src/app/pages/transaction-request/components/post-conditions/no-post-conditions.tsx b/src/app/features/stacks-transaction-request/post-conditions/no-post-conditions.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-conditions/no-post-conditions.tsx rename to src/app/features/stacks-transaction-request/post-conditions/no-post-conditions.tsx diff --git a/src/app/pages/transaction-request/components/post-conditions/post-condition-item.tsx b/src/app/features/stacks-transaction-request/post-conditions/post-condition-item.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-conditions/post-condition-item.tsx rename to src/app/features/stacks-transaction-request/post-conditions/post-condition-item.tsx diff --git a/src/app/pages/transaction-request/components/post-conditions/post-conditions-list.tsx b/src/app/features/stacks-transaction-request/post-conditions/post-conditions-list.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-conditions/post-conditions-list.tsx rename to src/app/features/stacks-transaction-request/post-conditions/post-conditions-list.tsx diff --git a/src/app/pages/transaction-request/components/post-conditions/post-conditions.tsx b/src/app/features/stacks-transaction-request/post-conditions/post-conditions.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-conditions/post-conditions.tsx rename to src/app/features/stacks-transaction-request/post-conditions/post-conditions.tsx diff --git a/src/app/pages/transaction-request/components/post-conditions/stx-post-condition.tsx b/src/app/features/stacks-transaction-request/post-conditions/stx-post-condition.tsx similarity index 100% rename from src/app/pages/transaction-request/components/post-conditions/stx-post-condition.tsx rename to src/app/features/stacks-transaction-request/post-conditions/stx-post-condition.tsx diff --git a/src/app/pages/transaction-request/components/principal-value.tsx b/src/app/features/stacks-transaction-request/principal-value.tsx similarity index 100% rename from src/app/pages/transaction-request/components/principal-value.tsx rename to src/app/features/stacks-transaction-request/principal-value.tsx diff --git a/src/app/pages/transaction-request/components/row.tsx b/src/app/features/stacks-transaction-request/row.tsx similarity index 100% rename from src/app/pages/transaction-request/components/row.tsx rename to src/app/features/stacks-transaction-request/row.tsx diff --git a/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx b/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx new file mode 100644 index 00000000000..845a1199259 --- /dev/null +++ b/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx @@ -0,0 +1,139 @@ +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; + +import { StacksTransaction } from '@stacks/transactions'; +import { Flex } from '@stacks/ui'; +import { Formik } from 'formik'; +import * as yup from 'yup'; + +import { HIGH_FEE_WARNING_LEARN_MORE_URL_STX } from '@shared/constants'; +import { FeeTypes } from '@shared/models/fees/fees.model'; +import { StacksTransactionFormValues } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; + +import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; +import { useOnMount } from '@app/common/hooks/use-on-mount'; +import { useRouteHeader } from '@app/common/hooks/use-route-header'; +import { stxToMicroStx } from '@app/common/money/unit-conversion'; +import { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; +import { nonceValidator } from '@app/common/validation/nonce-validators'; +import { EditNonceButton } from '@app/components/edit-nonce-button'; +import { NonceSetter } from '@app/components/nonce-setter'; +import { PopupHeader } from '@app/features/current-account/popup-header'; +import { RequestingTabClosedWarningMessage } from '@app/features/errors/requesting-tab-closed-error-msg'; +import { HighFeeDrawer } from '@app/features/high-fee-drawer/high-fee-drawer'; +import { ContractCallDetails } from '@app/features/stacks-transaction-request/contract-call-details/contract-call-details'; +import { ContractDeployDetails } from '@app/features/stacks-transaction-request/contract-deploy-details/contract-deploy-details'; +import { PageTop } from '@app/features/stacks-transaction-request/page-top'; +import { PostConditionModeWarning } from '@app/features/stacks-transaction-request/post-condition-mode-warning'; +import { PostConditions } from '@app/features/stacks-transaction-request/post-conditions/post-conditions'; +import { StxTransferDetails } from '@app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details'; +import { TransactionError } from '@app/features/stacks-transaction-request/transaction-error/transaction-error'; +import { useCurrentStacksAccountAnchoredBalances } from '@app/query/stacks/balance/stx-balance.hooks'; +import { useCalculateStacksTxFees } from '@app/query/stacks/fees/fees.hooks'; +import { useNextNonce } from '@app/query/stacks/nonce/account-nonces.hooks'; +import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; + +import { FeeForm } from './fee-form'; +import { MinimalErrorMessage } from './minimal-error-message'; +import { SubmitAction } from './submit-action'; + +interface StacksTransactionSignerProps { + stacksTransaction: StacksTransaction; + disableFeeSelection?: boolean; + disableNonceSelection?: boolean; + isMultisig: boolean; + + onCancel(): void; + + onSignStacksTransaction(fee: number, nonce: number): void; +} + +export function StacksTransactionSigner({ + stacksTransaction, + disableFeeSelection, + disableNonceSelection, + onSignStacksTransaction, + isMultisig, +}: StacksTransactionSignerProps) { + const transactionRequest = useTransactionRequestState(); + const { data: stxFees } = useCalculateStacksTxFees(stacksTransaction); + const analytics = useAnalytics(); + const { data: stacksBalances } = useCurrentStacksAccountAnchoredBalances(); + const navigate = useNavigate(); + const { data: nextNonce } = useNextNonce(); + const { search } = useLocation(); + + useRouteHeader(); + + useOnMount(() => { + void analytics.track('view_transaction_signing'), [analytics]; + }); + + const onSubmit = async (values: StacksTransactionFormValues) => { + onSignStacksTransaction(stxToMicroStx(values.fee).toNumber(), Number(values.nonce)); + }; + + if (!transactionRequest) return null; + + const validationSchema = + !transactionRequest.sponsored && !disableFeeSelection && !isMultisig + ? yup.object({ + fee: stxFeeValidator(stacksBalances?.stx.unlockedStx), + nonce: nonceValidator, + }) + : yup.object({ + nonce: nonceValidator, + }); + + const isNonceAlreadySet = !Number.isNaN(transactionRequest.nonce); + + const initialValues: StacksTransactionFormValues = { + fee: '', + feeCurrency: 'STX', + feeType: FeeTypes[FeeTypes.Middle], + nonce: isNonceAlreadySet ? transactionRequest.nonce : nextNonce?.nonce, + }; + + return ( + + + + + + + {transactionRequest.txType === 'contract_call' && } + {transactionRequest.txType === 'token_transfer' && } + {transactionRequest.txType === 'smart_contract' && } + + {() => ( + <> + {!isNonceAlreadySet && } + + {!disableNonceSelection && ( + navigate(RouteUrls.EditNonce + search)} + /> + )} + + + + + + )} + + + ); +} diff --git a/src/app/pages/transaction-request/components/stx-transfer-details/stx-transfer-details.tsx b/src/app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details.tsx similarity index 88% rename from src/app/pages/transaction-request/components/stx-transfer-details/stx-transfer-details.tsx rename to src/app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details.tsx index 70848d22fcd..0cafaa5012a 100644 --- a/src/app/pages/transaction-request/components/stx-transfer-details/stx-transfer-details.tsx +++ b/src/app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details.tsx @@ -2,8 +2,8 @@ import { Stack, color } from '@stacks/ui'; import { Divider } from '@app/components/layout/divider'; import { Title } from '@app/components/typography'; -import { AttachmentRow } from '@app/pages/transaction-request/components/attachment-row'; -import { Row } from '@app/pages/transaction-request/components/row'; +import { AttachmentRow } from '@app/features/stacks-transaction-request/attachment-row'; +import { Row } from '@app/features/stacks-transaction-request/row'; import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; export function StxTransferDetails(): React.JSX.Element | null { diff --git a/src/app/pages/transaction-request/components/submit-action.tsx b/src/app/features/stacks-transaction-request/submit-action.tsx similarity index 94% rename from src/app/pages/transaction-request/components/submit-action.tsx rename to src/app/features/stacks-transaction-request/submit-action.tsx index 43efc4b1468..0f4949a2a93 100644 --- a/src/app/pages/transaction-request/components/submit-action.tsx +++ b/src/app/features/stacks-transaction-request/submit-action.tsx @@ -10,7 +10,7 @@ import { isEmpty } from '@shared/utils'; import { useDrawers } from '@app/common/hooks/use-drawers'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { ButtonProps, LeatherButton } from '@app/components/button/button'; -import { useTransactionError } from '@app/pages/transaction-request/hooks/use-transaction-error'; +import { useTransactionError } from '@app/features/stacks-transaction-request/hooks/use-transaction-error'; function BaseConfirmButton(props: ButtonProps): React.JSX.Element { return ( diff --git a/src/app/pages/transaction-request/components/transaction-error/error-message.tsx b/src/app/features/stacks-transaction-request/transaction-error/error-message.tsx similarity index 100% rename from src/app/pages/transaction-request/components/transaction-error/error-message.tsx rename to src/app/features/stacks-transaction-request/transaction-error/error-message.tsx diff --git a/src/app/pages/transaction-request/components/transaction-error/error-messages.tsx b/src/app/features/stacks-transaction-request/transaction-error/error-messages.tsx similarity index 98% rename from src/app/pages/transaction-request/components/transaction-error/error-messages.tsx rename to src/app/features/stacks-transaction-request/transaction-error/error-messages.tsx index 97d052a587f..83cf5cc4f6b 100644 --- a/src/app/pages/transaction-request/components/transaction-error/error-messages.tsx +++ b/src/app/features/stacks-transaction-request/transaction-error/error-messages.tsx @@ -14,7 +14,7 @@ import { useScrollLock } from '@app/common/hooks/use-scroll-lock'; import { stacksValue } from '@app/common/stacks-utils'; import { LeatherButton } from '@app/components/button/button'; import { Caption } from '@app/components/typography'; -import { ErrorMessage } from '@app/pages/transaction-request/components/transaction-error/error-message'; +import { ErrorMessage } from '@app/features/stacks-transaction-request/transaction-error/error-message'; import { useCurrentStacksAccountAnchoredBalances } from '@app/query/stacks/balance/stx-balance.hooks'; import { useCurrentNetworkState } from '@app/store/networks/networks.hooks'; import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; diff --git a/src/app/pages/transaction-request/components/transaction-error/transaction-error.tsx b/src/app/features/stacks-transaction-request/transaction-error/transaction-error.tsx similarity index 94% rename from src/app/pages/transaction-request/components/transaction-error/transaction-error.tsx rename to src/app/features/stacks-transaction-request/transaction-error/transaction-error.tsx index b4544022d1c..2eafa04d6f1 100644 --- a/src/app/pages/transaction-request/components/transaction-error/transaction-error.tsx +++ b/src/app/features/stacks-transaction-request/transaction-error/transaction-error.tsx @@ -1,7 +1,7 @@ import { Suspense, memo, useEffect } from 'react'; import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { useTransactionError } from '@app/pages/transaction-request/hooks/use-transaction-error'; +import { useTransactionError } from '@app/features/stacks-transaction-request/hooks/use-transaction-error'; import { ExpiredRequestErrorMessage, diff --git a/src/app/pages/rpc-sign-stacks-transaction/rpc-sign-stacks-transaction.tsx b/src/app/pages/rpc-sign-stacks-transaction/rpc-sign-stacks-transaction.tsx new file mode 100644 index 00000000000..ef424ee3532 --- /dev/null +++ b/src/app/pages/rpc-sign-stacks-transaction/rpc-sign-stacks-transaction.tsx @@ -0,0 +1,24 @@ +import { StacksTransactionSigner } from '@app/features/stacks-transaction-request/stacks-transaction-signer'; +import { useRpcSignStacksTransaction } from '@app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction'; + +export function RpcSignStacksTransaction() { + const { + onSignStacksTransaction, + onCancel, + disableFeeSelection, + stacksTransaction, + disableNonceSelection, + isMultisig, + } = useRpcSignStacksTransaction(); + + return ( + + ); +} diff --git a/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts b/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts new file mode 100644 index 00000000000..1498cc40575 --- /dev/null +++ b/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { RpcErrorCode } from '@btckit/types'; +import { bytesToHex } from '@stacks/common'; +import { MultiSigSpendingCondition, deserializeTransaction } from '@stacks/transactions'; + +import { makeRpcErrorResponse, makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; + +import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; +import { useRejectIfLedgerWallet } from '@app/common/rpc-helpers'; +import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transaction.hooks'; + +function useRpcSignStacksTransactionParams() { + useRejectIfLedgerWallet('stx_signTransaction'); + + const [searchParams] = useSearchParams(); + const { origin, tabId } = useDefaultRequestParams(); + const requestId = searchParams.get('requestId'); + const txHex = searchParams.get('txHex'); + const isMultisig = searchParams.get('isMultisig'); + + if (!requestId || !txHex || !origin) throw new Error('Invalid params'); + + return useMemo( + () => ({ + origin, + tabId: tabId ?? 0, + requestId, + isMultisig: isMultisig === 'true', + stacksTransaction: deserializeTransaction(txHex), + }), + [origin, txHex, requestId, isMultisig, tabId] + ); +} + +export function useRpcSignStacksTransaction() { + const { origin, requestId, tabId, stacksTransaction, isMultisig } = + useRpcSignStacksTransactionParams(); + const signSoftwareWalletTx = useSignTransactionSoftwareWallet(); + const wasSignedByOtherOwners = + isMultisig && + (stacksTransaction.auth.spendingCondition as MultiSigSpendingCondition).fields?.length > 0; + + return { + origin, + disableFeeSelection: wasSignedByOtherOwners, + disableNonceSelection: wasSignedByOtherOwners, + stacksTransaction, + isMultisig, + onSignStacksTransaction(fee: number, nonce: number) { + stacksTransaction.setFee(fee); + stacksTransaction.setNonce(nonce); + + const signedTransaction = signSoftwareWalletTx(stacksTransaction); + if (!signedTransaction) { + throw new Error('Error signing stacks transaction'); + } + + chrome.tabs.sendMessage( + tabId, + makeRpcSuccessResponse('stx_signTransaction', { + id: requestId, + result: { + txHex: bytesToHex(signedTransaction.serialize()), + }, + }) + ); + window.close(); + }, + onCancel() { + chrome.tabs.sendMessage( + tabId, + makeRpcErrorResponse('stx_signTransaction', { + id: requestId, + error: { + message: 'User denied signing stacks transaction', + code: RpcErrorCode.USER_REJECTION, + }, + }) + ); + }, + }; +} diff --git a/src/app/pages/send/send-crypto-asset-form/form/stacks/stacks-common-send-form.tsx b/src/app/pages/send/send-crypto-asset-form/form/stacks/stacks-common-send-form.tsx index 8f33352d721..ecf44a7b924 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/stacks/stacks-common-send-form.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/stacks/stacks-common-send-form.tsx @@ -58,7 +58,8 @@ export function StacksCommonSendForm({ {props => { onFormStateChange(props.values); return ( - + <> +
{amountField} @@ -76,7 +77,7 @@ export function StacksCommonSendForm({ -
+ ); }} diff --git a/src/app/pages/transaction-request/transaction-request.tsx b/src/app/pages/transaction-request/transaction-request.tsx index 52e1da8ee20..c86651f30ab 100644 --- a/src/app/pages/transaction-request/transaction-request.tsx +++ b/src/app/pages/transaction-request/transaction-request.tsx @@ -24,13 +24,16 @@ import { PopupHeader } from '@app/features/current-account/popup-header'; import { RequestingTabClosedWarningMessage } from '@app/features/errors/requesting-tab-closed-error-msg'; import { HighFeeDrawer } from '@app/features/high-fee-drawer/high-fee-drawer'; import { useLedgerNavigate } from '@app/features/ledger/hooks/use-ledger-navigate'; -import { ContractCallDetails } from '@app/pages/transaction-request/components/contract-call-details/contract-call-details'; -import { ContractDeployDetails } from '@app/pages/transaction-request/components/contract-deploy-details/contract-deploy-details'; -import { PageTop } from '@app/pages/transaction-request/components/page-top'; -import { PostConditionModeWarning } from '@app/pages/transaction-request/components/post-condition-mode-warning'; -import { PostConditions } from '@app/pages/transaction-request/components/post-conditions/post-conditions'; -import { StxTransferDetails } from '@app/pages/transaction-request/components/stx-transfer-details/stx-transfer-details'; -import { TransactionError } from '@app/pages/transaction-request/components/transaction-error/transaction-error'; +import { ContractCallDetails } from '@app/features/stacks-transaction-request/contract-call-details/contract-call-details'; +import { ContractDeployDetails } from '@app/features/stacks-transaction-request/contract-deploy-details/contract-deploy-details'; +import { FeeForm } from '@app/features/stacks-transaction-request/fee-form'; +import { MinimalErrorMessage } from '@app/features/stacks-transaction-request/minimal-error-message'; +import { PageTop } from '@app/features/stacks-transaction-request/page-top'; +import { PostConditionModeWarning } from '@app/features/stacks-transaction-request/post-condition-mode-warning'; +import { PostConditions } from '@app/features/stacks-transaction-request/post-conditions/post-conditions'; +import { StxTransferDetails } from '@app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details'; +import { SubmitAction } from '@app/features/stacks-transaction-request/submit-action'; +import { TransactionError } from '@app/features/stacks-transaction-request/transaction-error/transaction-error'; import { useCurrentStacksAccountAnchoredBalances } from '@app/query/stacks/balance/stx-balance.hooks'; import { useCalculateStacksTxFees } from '@app/query/stacks/fees/fees.hooks'; import { useNextNonce } from '@app/query/stacks/nonce/account-nonces.hooks'; @@ -41,10 +44,6 @@ import { useUnsignedStacksTransactionBaseState, } from '@app/store/transactions/transaction.hooks'; -import { FeeForm } from './components/fee-form'; -import { MinimalErrorMessage } from './components/minimal-error-message'; -import { SubmitAction } from './components/submit-action'; - function TransactionRequestBase() { const transactionRequest = useTransactionRequestState(); const { setIsLoading, setIsIdle } = useLoading(LoadingKeys.SUBMIT_TRANSACTION); @@ -125,17 +124,16 @@ function TransactionRequestBase() { > {() => ( <> - - - navigate(RouteUrls.EditNonce)} - /> - - - - + + + navigate(RouteUrls.EditNonce)} + /> + + + )} diff --git a/src/app/routes/app-routes.tsx b/src/app/routes/app-routes.tsx index 9db1689f590..1ef80f077f9 100644 --- a/src/app/routes/app-routes.tsx +++ b/src/app/routes/app-routes.tsx @@ -45,6 +45,7 @@ import { RpcGetAddresses } from '@app/pages/rpc-get-addresses/rpc-get-addresses' import { rpcSendTransferRoutes } from '@app/pages/rpc-send-transfer/rpc-send-transfer.routes'; import { RpcSignPsbt } from '@app/pages/rpc-sign-psbt/rpc-sign-psbt'; import { RpcSignPsbtSummary } from '@app/pages/rpc-sign-psbt/rpc-sign-psbt-summary'; +import { RpcSignStacksTransaction } from '@app/pages/rpc-sign-stacks-transaction/rpc-sign-stacks-transaction'; import { SelectNetwork } from '@app/pages/select-network/select-network'; import { BroadcastError } from '@app/pages/send/broadcast-error/broadcast-error'; import { LockBitcoinSummary } from '@app/pages/send/locked-bitcoin-summary/locked-bitcoin-summary'; @@ -342,6 +343,17 @@ function useAppRoutes() { } /> + + + + } + > + } /> + + { diff --git a/src/app/store/transactions/requests.hooks.ts b/src/app/store/transactions/requests.hooks.ts index 0a96c6b14ef..1adf80e6b16 100644 --- a/src/app/store/transactions/requests.hooks.ts +++ b/src/app/store/transactions/requests.hooks.ts @@ -10,8 +10,11 @@ export function useTransactionRequest() { export function useTransactionRequestState() { const requestToken = useTransactionRequest(); + return useMemo(() => { - if (!requestToken) return null; + if (!requestToken) { + return null; + } return getPayloadFromToken(requestToken); }, [requestToken]); } diff --git a/src/background/messaging/rpc-message-handler.ts b/src/background/messaging/rpc-message-handler.ts index bddeb907c35..5856cc955d8 100644 --- a/src/background/messaging/rpc-message-handler.ts +++ b/src/background/messaging/rpc-message-handler.ts @@ -2,6 +2,8 @@ import { RpcErrorCode } from '@btckit/types'; import { WalletRequests, makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; +import { rpcSignStacksTransaction } from '@background/messaging/rpc-methods/sign-stacks-transaction'; + import { getTabIdFromPort } from './messaging-utils'; import { rpcAcceptBitcoinContractOffer } from './rpc-methods/accept-bitcoin-contract'; import { rpcGetAddresses } from './rpc-methods/get-addresses'; @@ -32,6 +34,11 @@ export async function rpcMessageHandler(message: WalletRequests, port: chrome.ru break; } + case 'stx_signTransaction': { + await rpcSignStacksTransaction(message, port); + break; + } + case 'supportedMethods': { rpcSupportedMethods(message, port); break; diff --git a/src/background/messaging/rpc-methods/sign-stacks-transaction.ts b/src/background/messaging/rpc-methods/sign-stacks-transaction.ts new file mode 100644 index 00000000000..453f0cfd96f --- /dev/null +++ b/src/background/messaging/rpc-methods/sign-stacks-transaction.ts @@ -0,0 +1,188 @@ +import { RpcErrorCode } from '@btckit/types'; +import { bytesToHex } from '@stacks/common'; +import { TransactionTypes } from '@stacks/connect'; +import { + AddressHashMode, + AuthType, + MultiSigHashMode, + PayloadType, + PostCondition, + StacksTransaction, + VersionedSmartContractPayload, + addressToString, + cvToValue, + deserializeTransaction, + serializeCV, + serializePostCondition, +} from '@stacks/transactions'; +import BigNumber from 'bignumber.js'; +import { createUnsecuredToken } from 'jsontokens'; + +import { STX_DECIMALS } from '@shared/constants'; +import { RouteUrls } from '@shared/route-urls'; +import { + SignStacksTransactionRequest, + getRpcSignStacksTransactionParamErrors, + validateRpcSignStacksTransactionParams, +} from '@shared/rpc/methods/sign-stacks-transaction'; +import { makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; +import { isDefined, isUndefined } from '@shared/utils'; + +import { + RequestParams, + getTabIdFromPort, + listenForPopupClose, + makeSearchParamsWithDefaults, + triggerRequestWindowOpen, +} from '../messaging-utils'; + +const MEMO_DESERIALIZATION_STUB = '\u0000'; + +const cleanMemoString = (memo: string): string => { + return memo.replaceAll(MEMO_DESERIALIZATION_STUB, ''); +}; + +function encodePostConditions(postConditions: PostCondition[]) { + return postConditions.map(pc => bytesToHex(serializePostCondition(pc))); +} + +const transactionPayloadToTransactionRequest = ( + stacksTransaction: StacksTransaction, + stxAddress?: string, + attachment?: string +) => { + const transactionRequest = { + attachment, + stxAddress, + sponsored: stacksTransaction.auth.authType === AuthType.Sponsored, + nonce: Number(stacksTransaction.auth.spendingCondition.nonce), + fee: Number(stacksTransaction.auth.spendingCondition.fee), + postConditions: encodePostConditions(stacksTransaction.postConditions.values as any[]), + postConditionMode: stacksTransaction.postConditionMode, + anchorMode: stacksTransaction.anchorMode, + } as any; + + switch (stacksTransaction.payload.payloadType) { + case PayloadType.TokenTransfer: + transactionRequest.txType = TransactionTypes.STXTransfer; + transactionRequest.recipient = cvToValue(stacksTransaction.payload.recipient, true); + transactionRequest.amount = new BigNumber(Number(stacksTransaction.payload.amount)) + .shiftedBy(-STX_DECIMALS) + .toNumber() + .toLocaleString('en-US', { maximumFractionDigits: STX_DECIMALS }); + transactionRequest.memo = cleanMemoString(stacksTransaction.payload.memo.content); + break; + case PayloadType.ContractCall: + transactionRequest.txType = TransactionTypes.ContractCall; + transactionRequest.contractName = stacksTransaction.payload.contractName.content; + transactionRequest.contractAddress = addressToString( + stacksTransaction.payload.contractAddress + ); + transactionRequest.functionArgs = stacksTransaction.payload.functionArgs.map(arg => + Buffer.from(serializeCV(arg)).toString('hex') + ); + transactionRequest.functionName = stacksTransaction.payload.functionName.content; + break; + case PayloadType.SmartContract: + case PayloadType.VersionedSmartContract: + transactionRequest.txType = TransactionTypes.ContractDeploy; + transactionRequest.contractName = stacksTransaction.payload.contractName.content; + transactionRequest.codeBody = stacksTransaction.payload.codeBody.content; + transactionRequest.clarityVersion = ( + stacksTransaction.payload as VersionedSmartContractPayload + ).clarityVersion; + break; + default: + throw new Error('Unsupported tx type'); + } + + return transactionRequest; +}; + +function validateStacksTransaction(txHex: string) { + try { + deserializeTransaction(txHex); + return true; + } catch (e) { + return false; + } +} + +export async function rpcSignStacksTransaction( + message: SignStacksTransactionRequest, + port: chrome.runtime.Port +) { + if (isUndefined(message.params)) { + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_signTransaction', { + id: message.id, + error: { code: RpcErrorCode.INVALID_REQUEST, message: 'Parameters undefined' }, + }) + ); + return; + } + + if (!validateRpcSignStacksTransactionParams(message.params)) { + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_signTransaction', { + id: message.id, + error: { + code: RpcErrorCode.INVALID_PARAMS, + message: getRpcSignStacksTransactionParamErrors(message.params), + }, + }) + ); + return; + } + + if (!validateStacksTransaction(message.params.txHex!)) { + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_signTransaction', { + id: message.id, + error: { code: RpcErrorCode.INVALID_PARAMS, message: 'Invalid Stacks transaction hex' }, + }) + ); + return; + } + + const stacksTransaction = deserializeTransaction(message.params.txHex!); + const request = transactionPayloadToTransactionRequest( + stacksTransaction, + message.params.stxAddress, + message.params.attachment + ); + + const hashMode = stacksTransaction.auth.spendingCondition.hashMode as MultiSigHashMode; + const isMultisig = + hashMode === AddressHashMode.SerializeP2SH || hashMode === AddressHashMode.SerializeP2WSH; + + const requestParams = [ + ['txHex', message.params.txHex], + ['requestId', message.id], + ['request', createUnsecuredToken(request)], + ['isMultisig', isMultisig], + ] as RequestParams; + + if (isDefined(message.params.network)) { + requestParams.push(['network', message.params.network]); + } + + const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams); + + const { id } = await triggerRequestWindowOpen(RouteUrls.RpcSignStacksTransaction, urlParams); + + listenForPopupClose({ + tabId, + id, + response: makeRpcErrorResponse('stx_signTransaction', { + id: message.id, + error: { + code: RpcErrorCode.USER_REJECTION, + message: 'User rejected the Stacks transaction signing request', + }, + }), + }); +} diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index 85b76f5a1cb..266fe6478c7 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -97,7 +97,7 @@ export enum RouteUrls { TransactionRequest = '/transaction', TransactionBroadcastError = 'broadcast-error', - // Rpc request routes + // Request routes bitcoin RpcGetAddresses = '/get-addresses', RpcSignPsbt = '/sign-psbt', RpcSignPsbtSummary = '/sign-psbt/summary', @@ -111,4 +111,7 @@ export enum RouteUrls { // Shared legacy and rpc request routes RequestError = '/request-error', UnauthorizedRequest = '/unauthorized-request', + + // Request routes stacks + RpcSignStacksTransaction = '/sign-stacks-transaction', } diff --git a/src/shared/rpc/methods/sign-stacks-transaction.ts b/src/shared/rpc/methods/sign-stacks-transaction.ts new file mode 100644 index 00000000000..a950788f64a --- /dev/null +++ b/src/shared/rpc/methods/sign-stacks-transaction.ts @@ -0,0 +1,36 @@ +import { DefineRpcMethod, RpcRequest, RpcResponse } from '@btckit/types'; +import { StacksNetworks } from '@stacks/network'; +import * as yup from 'yup'; + +import { formatValidationErrors, getRpcParamErrors, validateRpcParams } from './validation.utils'; + +const rpcSignStacksTransactionParamsSchema = yup.object().shape({ + stxAddress: yup.string(), + txHex: yup.string().required(), + attachment: yup.string(), + network: yup.string().oneOf(StacksNetworks), +}); + +export function validateRpcSignStacksTransactionParams(obj: unknown) { + return validateRpcParams(obj, rpcSignStacksTransactionParamsSchema); +} + +export function getRpcSignStacksTransactionParamErrors(obj: unknown) { + return formatValidationErrors(getRpcParamErrors(obj, rpcSignStacksTransactionParamsSchema)); +} + +type SignStacksTransactionRequestParams = yup.InferType< + typeof rpcSignStacksTransactionParamsSchema +>; + +export type SignStacksTransactionRequest = RpcRequest< + 'stx_signTransaction', + SignStacksTransactionRequestParams +>; + +type SignStacksTransactionResponse = RpcResponse<{ txHex: string }>; + +export type SignStacksTransaction = DefineRpcMethod< + SignStacksTransactionRequest, + SignStacksTransactionResponse +>; diff --git a/src/shared/rpc/rpc-methods.ts b/src/shared/rpc/rpc-methods.ts index 87f2d7c7214..7384565e5c7 100644 --- a/src/shared/rpc/rpc-methods.ts +++ b/src/shared/rpc/rpc-methods.ts @@ -1,5 +1,6 @@ import { BtcKitMethodMap, ExtractErrorResponse, ExtractSuccessResponse } from '@btckit/types'; +import { SignStacksTransaction } from '@shared/rpc/methods/sign-stacks-transaction'; import { ValueOf } from '@shared/utils/type-utils'; import { AcceptBitcoinContract } from './methods/accept-bitcoin-contract'; @@ -7,7 +8,11 @@ import { SignPsbt } from './methods/sign-psbt'; import { SupportedMethods } from './methods/supported-methods'; // Supports BtcKit methods, as well as custom Leather methods -export type WalletMethodMap = BtcKitMethodMap & SupportedMethods & SignPsbt & AcceptBitcoinContract; +export type WalletMethodMap = BtcKitMethodMap & + SupportedMethods & + SignPsbt & + AcceptBitcoinContract & + SignStacksTransaction; export type WalletRequests = ValueOf['request']; export type WalletResponses = ValueOf['response']; diff --git a/tests/mocks/constants.ts b/tests/mocks/constants.ts index e726add1149..ccc643142b8 100644 --- a/tests/mocks/constants.ts +++ b/tests/mocks/constants.ts @@ -5,12 +5,15 @@ export const TEST_ACCOUNT_1_TAPROOT_ADDRESS = // export const TEST_TESTNET_ACCOUNT_1_BTC_ADDRESS = 'tb1q3c7zyg58dd9hy07m77dv8es9vnpk8xad0yaw8y' export const TEST_ACCOUNT_1_STX_ADDRESS = 'SPS8CKF63P16J28AYF7PXW9E5AACH0NZNTEFWSFE'; -// export const TEST_ACCOUNT_1_PUBKEY = -// '02b6b0afe5f620bc8e532b640b148dd9dea0ed19d11f8ab420fcce488fe3974893'; +export const TEST_ACCOUNT_1_PUBKEY = + '02b6b0afe5f620bc8e532b640b148dd9dea0ed19d11f8ab420fcce488fe3974893'; export const TEST_ACCOUNT_2_STX_ADDRESS = 'SPRE7HABZGQ204G3VQAKMDMVBBD8A8CYG6BQKHQ'; export const TEST_TESTNET_ACCOUNT_2_STX_ADDRESS = 'STXH3HNBPM5YP15VH16ZXZ9AX6CK289K3NVR9T1P'; // export const TEST_ACCOUNT_2_BTC_ADDRESS = 'bc1qznkpz8fk07nmdhvr2k4nnea5n08tw6tk540snu'; export const TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS = 'tb1qkzvk9hr7uvas23hspvsgqfvyc8h4nngeqjqtnj'; +// export const TEST_ACCOUNT_3_STX_ADDRESS = 'SP297VG59W96DPGBT13SGD542QE1XS954X78Z75G0' +export const TEST_ACCOUNT_3_PUBKEY = + '03c1e856462ca2844adb898aee90af5237e9d1be0fe51212635b2f7a643b0585e1'; export const TEST_BNS_NAME = 'test-hiro-wallet.btc'; export const TEST_BNS_RESOLVED_ADDRESS = 'SP12YQ0M2KFT7YMJKVGP71B874YF055F77PFPH9KM'; diff --git a/tests/specs/rpc-stacks-transaction/transaction-signing.spec.ts b/tests/specs/rpc-stacks-transaction/transaction-signing.spec.ts new file mode 100644 index 00000000000..cd2d765472c --- /dev/null +++ b/tests/specs/rpc-stacks-transaction/transaction-signing.spec.ts @@ -0,0 +1,130 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { MultiSigSpendingCondition, deserializeTransaction } from '@stacks/transactions'; +import { TokenTransferPayload } from '@stacks/transactions/dist/payload'; +import { + TEST_ACCOUNT_1_PUBKEY, + TEST_ACCOUNT_2_STX_ADDRESS, + TEST_ACCOUNT_3_PUBKEY, +} from '@tests/mocks/constants'; +import { generateMultisigUnsignedStxTransfer, generateUnsignedStxTransfer } from '@tests/utils'; + +import { test } from '../../fixtures/fixtures'; + +test.describe('Transaction signing', () => { + test.beforeEach(async ({ extensionId, globalPage, onboardingPage, page }) => { + await globalPage.setupAndUseApiCalls(extensionId); + await onboardingPage.signInWithTestAccount(extensionId); + await page.goto('https://leather.io'); + }); + + function checkVisibleContent(context: BrowserContext) { + return async (buttonToPress: 'Cancel' | 'Confirm') => { + const popup = await context.waitForEvent('page'); + await popup.waitForSelector('text="' + TEST_ACCOUNT_2_STX_ADDRESS + '"'); + await popup.waitForSelector(`text="${500 * 0.000001}"`); + await popup.waitForTimeout(500); + const btn = popup.locator('text="Confirm"'); + + if (buttonToPress === 'Confirm') { + await btn.click(); + } else { + await popup.close(); + } + }; + } + + function initiateTxSigning(page: Page) { + return async (txHex: string) => + page.evaluate( + async txHex => + (window as any).HiroWalletProvider.request('stx_signTransaction', { + txHex, + network: 'mainnet', + }).catch((e: unknown) => e), + txHex + ); + } + + test('that transaction details are the same after signing multi-signature STX transfer', async ({ + page, + context, + }) => { + const amount = 500; + const multiSignatureTxHex = await generateMultisigUnsignedStxTransfer( + TEST_ACCOUNT_2_STX_ADDRESS, + amount, + 'mainnet', + [TEST_ACCOUNT_3_PUBKEY, TEST_ACCOUNT_1_PUBKEY], + 2, + 0 + ); + const [result] = await Promise.all([ + initiateTxSigning(page)(multiSignatureTxHex), + checkVisibleContent(context)('Confirm'), + ]); + + // deserialize both transactions + const deserializedUnsignedTxHex = deserializeTransaction(multiSignatureTxHex); + const deserializedSignedTx = deserializeTransaction(result.result.txHex); + // compare transactions + test + .expect((deserializedUnsignedTxHex.payload as TokenTransferPayload).recipient) + .toEqual((deserializedSignedTx.payload as TokenTransferPayload).recipient); + test + .expect((deserializedUnsignedTxHex.payload as TokenTransferPayload).amount) + .toEqual((deserializedSignedTx.payload as TokenTransferPayload).amount); + test.expect(deserializedUnsignedTxHex.payload.type).toEqual(deserializedSignedTx.payload.type); + test + .expect(deserializedUnsignedTxHex.auth.spendingCondition.nonce) + .toEqual(deserializedSignedTx.auth.spendingCondition.nonce); + test + .expect(deserializedUnsignedTxHex.auth.spendingCondition.fee) + .toEqual(deserializedSignedTx.auth.spendingCondition.fee); + test + .expect( + (deserializedUnsignedTxHex.auth.spendingCondition as MultiSigSpendingCondition) + .signaturesRequired + ) + .toEqual( + (deserializedSignedTx.auth.spendingCondition as MultiSigSpendingCondition) + .signaturesRequired + ); + test + .expect(deserializedUnsignedTxHex.auth.spendingCondition.signer) + .toEqual(deserializedSignedTx.auth.spendingCondition.signer); + test + .expect(deserializedUnsignedTxHex.auth.spendingCondition.hashMode) + .toEqual(deserializedSignedTx.auth.spendingCondition.hashMode); + // check that the transaction is signed + test + .expect( + (deserializedSignedTx.auth.spendingCondition as MultiSigSpendingCondition).fields.length + ) + .toEqual(1); + }); + + test('Single signature STX transfer being rejected', async ({ page, context }) => { + const amount = 500; + const singleSignatureTxHex = await generateUnsignedStxTransfer( + TEST_ACCOUNT_2_STX_ADDRESS, + amount, + 'mainnet', + TEST_ACCOUNT_3_PUBKEY + ); + const [result] = await Promise.all([ + initiateTxSigning(page)(singleSignatureTxHex), + checkVisibleContent(context)('Cancel'), + ]); + + // ID is random, removed so we can test known values + delete result.id; + + test.expect(result).toEqual({ + jsonrpc: '2.0', + error: { + code: 4001, + message: 'User rejected the Stacks transaction signing request', + }, + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 26fcb538a3e..4fd69f04390 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,4 +1,6 @@ import { Locator } from '@playwright/test'; +import { bytesToHex } from '@stacks/common'; +import { AnchorMode, makeUnsignedSTXTokenTransfer } from '@stacks/transactions'; import { SharedComponentsSelectors } from './selectors/shared-component.selectors'; @@ -20,3 +22,45 @@ export async function getDisplayerAddress(locator: Locator) { return displayerAddress.replaceAll('\n', ''); } + +export async function generateUnsignedStxTransfer( + recipient: string, + amount: number, + network: any, + publicKey: string, + anchorMode?: AnchorMode, + memo?: string +) { + const options = { + recipient, + memo, + publicKey, + anchorMode: anchorMode ?? AnchorMode.Any, + amount, + network, + }; + return bytesToHex((await makeUnsignedSTXTokenTransfer(options)).serialize()); +} + +export async function generateMultisigUnsignedStxTransfer( + recipient: string, + amount: number, + network: any, + publicKeys: string[], + threshold: number, + nonce: number, + anchorMode?: AnchorMode, + memo?: string +) { + const options = { + recipient, + memo, + publicKeys, + nonce, + numSignatures: threshold, + anchorMode: anchorMode ?? AnchorMode.Any, + amount, + network, + }; + return bytesToHex((await makeUnsignedSTXTokenTransfer(options)).serialize()); +}