diff --git a/src/app/pages/stacks-message-signing-request/components/clarity-value-list.tsx b/src/app/features/stacks-message-signer/components/clarity-value-list.tsx similarity index 100% rename from src/app/pages/stacks-message-signing-request/components/clarity-value-list.tsx rename to src/app/features/stacks-message-signer/components/clarity-value-list.tsx diff --git a/src/app/pages/stacks-message-signing-request/components/message-signing-disclaimer.tsx b/src/app/features/stacks-message-signer/components/message-signing-disclaimer.tsx similarity index 95% rename from src/app/pages/stacks-message-signing-request/components/message-signing-disclaimer.tsx rename to src/app/features/stacks-message-signer/components/message-signing-disclaimer.tsx index 181f6222c67..5beb7e0a68e 100644 --- a/src/app/pages/stacks-message-signing-request/components/message-signing-disclaimer.tsx +++ b/src/app/features/stacks-message-signer/components/message-signing-disclaimer.tsx @@ -1,7 +1,7 @@ import { Disclaimer } from '@app/components/disclaimer'; interface DisclaimerProps { - appName?: string; + appName?: string | null; } export function StacksMessageSigningDisclaimer({ appName }: DisclaimerProps) { return ( diff --git a/src/app/pages/stacks-message-signing-request/components/nested-tuple-displayer.tsx b/src/app/features/stacks-message-signer/components/nested-tuple-displayer.tsx similarity index 100% rename from src/app/pages/stacks-message-signing-request/components/nested-tuple-displayer.tsx rename to src/app/features/stacks-message-signer/components/nested-tuple-displayer.tsx diff --git a/src/app/features/stacks-message-signer/components/stacks-signature-message-content.tsx b/src/app/features/stacks-message-signer/components/stacks-signature-message-content.tsx new file mode 100644 index 00000000000..7e0160ccf6c --- /dev/null +++ b/src/app/features/stacks-message-signer/components/stacks-signature-message-content.tsx @@ -0,0 +1,41 @@ +import { ChainID, bytesToHex } from '@stacks/common'; +import { hashMessage } from '@stacks/encryption'; + +import { UnsignedMessage } from '@shared/signature/signature-types'; + +import { NoFeesWarningRow } from '@app/components/no-fees-warning-row'; + +import { MessagePreviewBox } from '../../../features/message-signer/message-preview-box'; +import { SignMessageActions } from '../../../features/message-signer/stacks-sign-message-action'; +import { Utf8Payload } from '../stacks-message-signing'; +import { StacksMessageSigningDisclaimer } from './message-signing-disclaimer'; + +interface SignatureRequestMessageContentProps { + isLoading: boolean; + onSignMessage(unsignedMessage: UnsignedMessage): Promise; + onCancelMessageSigning(): void; + payload: Utf8Payload; +} +export function StacksSignatureRequestMessageContent({ + isLoading, + onSignMessage, + onCancelMessageSigning, + payload, +}: SignatureRequestMessageContentProps) { + return ( + <> + + + onSignMessage({ messageType: 'utf8', message: payload.message })} + /> +
+ + + ); +} diff --git a/src/app/pages/stacks-message-signing-request/components/structured-data-box.tsx b/src/app/features/stacks-message-signer/components/structured-data-box.tsx similarity index 100% rename from src/app/pages/stacks-message-signing-request/components/structured-data-box.tsx rename to src/app/features/stacks-message-signer/components/structured-data-box.tsx diff --git a/src/app/features/stacks-message-signer/components/structured-data-content.tsx b/src/app/features/stacks-message-signer/components/structured-data-content.tsx new file mode 100644 index 00000000000..b4043798c68 --- /dev/null +++ b/src/app/features/stacks-message-signer/components/structured-data-content.tsx @@ -0,0 +1,43 @@ +import { ChainID } from '@stacks/common'; + +import { UnsignedMessage } from '@shared/signature/signature-types'; + +import { NoFeesWarningRow } from '@app/components/no-fees-warning-row'; +import { SignMessageActions } from '@app/features/message-signer/stacks-sign-message-action'; + +import { StructuredPayload } from '../stacks-message-signing'; +import { StacksMessageSigningDisclaimer } from './message-signing-disclaimer'; +import { StructuredDataBox } from './structured-data-box'; + +interface SignatureRequestStructuredDataContentProps { + isLoading: boolean; + onSignMessage(unsignedMessage: UnsignedMessage): Promise; + onCancelMessageSigning(): void; + payload: StructuredPayload; +} +export function SignatureRequestStructuredDataContent({ + isLoading, + onSignMessage, + onCancelMessageSigning, + payload, +}: SignatureRequestStructuredDataContentProps) { + return ( + <> + + + + onSignMessage({ + messageType: 'structured', + message: payload.message, + domain: payload.domain, + }) + } + /> +
+ + + ); +} diff --git a/src/app/features/stacks-message-signer/stacks-message-signing.tsx b/src/app/features/stacks-message-signer/stacks-message-signing.tsx new file mode 100644 index 00000000000..a4193804182 --- /dev/null +++ b/src/app/features/stacks-message-signer/stacks-message-signing.tsx @@ -0,0 +1,89 @@ +import { Outlet } from 'react-router-dom'; + +import { StacksNetwork } from '@stacks/network'; +import { ClarityValue } from '@stacks/transactions/dist/esm/clarity'; + +import { + SignedMessageType, + StructuredMessageDataDomain, + UnsignedMessage, + isSignableMessageType, + isStructuredMessageType, + isUtf8MessageType, +} from '@shared/signature/signature-types'; +import { closeWindow } from '@shared/utils'; + +import { useRouteHeader } from '@app/common/hooks/use-route-header'; +import { PopupHeader } from '@app/features/current-account/popup-header'; +import { MessageSigningHeader } from '@app/features/message-signer/message-signing-header'; +import { useOnOriginTabClose } from '@app/routes/hooks/use-on-tab-closed'; + +import { MessageSigningRequestLayout } from '../message-signer/message-signing-request.layout'; +import { StacksSignatureRequestMessageContent } from './components/stacks-signature-message-content'; +import { SignatureRequestStructuredDataContent } from './components/structured-data-content'; + +export interface Utf8Payload { + messageType: 'utf8'; + message: string; + network: StacksNetwork | undefined; + appName: string | undefined | null; +} + +export interface StructuredPayload { + messageType: 'structured'; + message: ClarityValue; + network: StacksNetwork | undefined; + appName: string | undefined | null; + domain: StructuredMessageDataDomain; +} + +interface StacksMessageSigningProps { + messageType: SignedMessageType; + tabId: number | null; + origin: string | null; + isLoading: boolean; + onSignMessage(unsignedMessage: UnsignedMessage): Promise; + onCancelMessageSigning(): void; + payload: Utf8Payload | StructuredPayload; +} + +export function StacksMessageSigning({ + messageType, + tabId, + origin, + isLoading, + onSignMessage, + onCancelMessageSigning, + payload, +}: StacksMessageSigningProps) { + useRouteHeader(); + useOnOriginTabClose(() => closeWindow()); + + if (!tabId) return null; + if (!isSignableMessageType(messageType)) return null; + if (!origin) return null; + + return ( + + + + {isUtf8MessageType(messageType) && payload.messageType === 'utf8' && ( + + )} + {isStructuredMessageType(messageType) && payload.messageType === 'structured' && ( + + )} + + + ); +} diff --git a/src/app/features/stacks-message-signer/stacks-message-signing.utils.ts b/src/app/features/stacks-message-signer/stacks-message-signing.utils.ts new file mode 100644 index 00000000000..f08152512cb --- /dev/null +++ b/src/app/features/stacks-message-signer/stacks-message-signing.utils.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; + +import { ClarityValue, TupleCV, createStacksPrivateKey } from '@stacks/transactions'; + +import { signMessage, signStructuredDataMessage } from '@shared/crypto/sign-message'; +import { isString } from '@shared/utils'; + +import { createDelay } from '@app/common/utils'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +export const improveUxWithShortDelayAsStacksSigningIsSoFast = createDelay(1000); + +export function useMessageSignerStacksSoftwareWallet() { + const account = useCurrentStacksAccount(); + return useCallback( + ({ message, domain }: { message: string | ClarityValue; domain?: TupleCV }) => { + if (!account || account.type === 'ledger') return null; + + const privateKey = createStacksPrivateKey(account.stxPrivateKey); + + if (isString(message)) { + return signMessage(message, privateKey); + } else { + if (!domain) throw new Error('Domain is required for structured messages'); + + // returns signature in RSV format + return signStructuredDataMessage(message, domain, privateKey); + } + }, + [account] + ); +} diff --git a/src/app/features/stacks-message-signer/use-sign-stacks-message.ts b/src/app/features/stacks-message-signer/use-sign-stacks-message.ts new file mode 100644 index 00000000000..285f562f3ec --- /dev/null +++ b/src/app/features/stacks-message-signer/use-sign-stacks-message.ts @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +import { SignatureData } from '@stacks/connect'; + +import { logger } from '@shared/logger'; +import { UnsignedMessage, whenSignableMessageOfType } from '@shared/signature/signature-types'; + +import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; +import { useWalletType } from '@app/common/use-wallet-type'; +import { useLedgerNavigate } from '@app/features/ledger/hooks/use-ledger-navigate'; +import { + improveUxWithShortDelayAsStacksSigningIsSoFast, + useMessageSignerStacksSoftwareWallet, +} from '@app/features/stacks-message-signer/stacks-message-signing.utils'; + +interface SignStacksMessageProps { + onSignMessageCompleted(messageSignature: SignatureData): void; +} + +export function useSignStacksMessage({ onSignMessageCompleted }: SignStacksMessageProps) { + const analytics = useAnalytics(); + const signSoftwareWalletMessage = useMessageSignerStacksSoftwareWallet(); + + const { whenWallet } = useWalletType(); + const ledgerNavigate = useLedgerNavigate(); + + const [isLoading, setIsLoading] = useState(false); + + const signMessage = whenWallet({ + async software(unsignedMessage: UnsignedMessage) { + setIsLoading(true); + void analytics.track('request_signature_sign', { type: 'software' }); + + const messageSignature = signSoftwareWalletMessage(unsignedMessage); + + if (!messageSignature) { + logger.error('Cannot sign message, no account in state'); + void analytics.track('request_signature_cannot_sign_message_no_account'); + return; + } + await improveUxWithShortDelayAsStacksSigningIsSoFast(); + setIsLoading(false); + + onSignMessageCompleted(messageSignature); + }, + + async ledger(unsignedMessage: UnsignedMessage) { + void analytics.track('request_signature_sign', { type: 'ledger' }); + whenSignableMessageOfType(unsignedMessage)({ + utf8(msg) { + ledgerNavigate.toConnectAndSignUtf8MessageStep(msg); + }, + structured(domain, msg) { + ledgerNavigate.toConnectAndSignStructuredMessageStep(domain, msg); + }, + }); + }, + }); + + return { isLoading, signMessage }; +} diff --git a/src/app/pages/rpc-sign-stacks-message/rpc-sign-stacks-message.tsx b/src/app/pages/rpc-sign-stacks-message/rpc-sign-stacks-message.tsx new file mode 100644 index 00000000000..d2d28bc9f12 --- /dev/null +++ b/src/app/pages/rpc-sign-stacks-message/rpc-sign-stacks-message.tsx @@ -0,0 +1,32 @@ +import { isSignableMessageType } from '@shared/signature/signature-types'; + +import { StacksMessageSigning } from '@app/features/stacks-message-signer/stacks-message-signing'; + +import { + useRpcSignStacksMessage, + useRpcSignStacksMessageParams, + useRpcStacksMessagePayload, +} from './use-rpc-sign-stacks-message'; + +export function RpcStacksMessageSigning() { + const { requestId, messageType, tabId, origin } = useRpcSignStacksMessageParams(); + const { isLoading, signMessage, onCancelMessageSigning } = useRpcSignStacksMessage(); + const payload = useRpcStacksMessagePayload(); + + if (!requestId || !tabId) return null; + if (!isSignableMessageType(messageType)) return null; + if (!origin) return null; + if (!payload) return null; + + return ( + + ); +} diff --git a/src/app/pages/rpc-sign-stacks-message/use-rpc-sign-stacks-message.ts b/src/app/pages/rpc-sign-stacks-message/use-rpc-sign-stacks-message.ts new file mode 100644 index 00000000000..f0e6e5b42ec --- /dev/null +++ b/src/app/pages/rpc-sign-stacks-message/use-rpc-sign-stacks-message.ts @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { RpcErrorCode } from '@btckit/types'; +import { StacksNetwork } from '@stacks/network'; +import { deserializeCV } from '@stacks/transactions'; + +import { makeRpcErrorResponse, makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; +import { + isSignableMessageType, + isStructuredMessageType, + isUtf8MessageType, +} from '@shared/signature/signature-types'; +import { closeWindow } from '@shared/utils'; + +import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; +import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; +import { + StructuredPayload, + Utf8Payload, +} from '@app/features/stacks-message-signer/stacks-message-signing'; +import { useSignStacksMessage } from '@app/features/stacks-message-signer/use-sign-stacks-message'; + +function getNetwork(networkName: string | null) { + if ( + networkName === 'mainnet' || + networkName === 'testnet' || + networkName === 'devnet' || + networkName === 'mocknet' + ) { + return StacksNetwork.fromName(networkName); + } + return; +} + +export function useRpcStacksMessagePayload() { + const { messageType, message, network, appName, domain } = useRpcSignStacksMessageParams(); + + if (isUtf8MessageType(messageType)) { + return { + messageType: 'utf8' as const, + message, + network: getNetwork(network), + appName, + } satisfies Utf8Payload; + } + if (isStructuredMessageType(messageType)) { + if (!domain) return null; + + return { + messageType: 'structured' as const, + message: deserializeCV(Buffer.from(message, 'hex')), + domain: deserializeCV(Buffer.from(domain, 'hex')), + network: getNetwork(network), + appName, + } satisfies StructuredPayload; + } + return null; +} + +export function useRpcSignStacksMessageParams() { + const [searchParams] = useSearchParams(); + const { origin, tabId } = useDefaultRequestParams(); + const requestId = searchParams.get('requestId'); + const network = searchParams.get('network'); + const appName = searchParams.get('appName'); + const message = searchParams.get('message'); + const messageType = searchParams.get('messageType'); + const domain = searchParams.get('domain'); + + if (!requestId || !message || !origin || !isSignableMessageType(messageType)) + throw new Error('Invalid params'); + + return useMemo( + () => ({ + origin, + tabId: tabId ?? 0, + requestId, + network, + message, + messageType, + appName, + domain, + }), + [origin, requestId, network, message, messageType, tabId, appName, domain] + ); +} + +export function useRpcSignStacksMessage() { + const analytics = useAnalytics(); + + const { tabId, requestId } = useRpcSignStacksMessageParams(); + if (!tabId) throw new Error('Requests can only be made with corresponding tab'); + + const { isLoading, signMessage } = useSignStacksMessage({ + onSignMessageCompleted: messageSignature => { + chrome.tabs.sendMessage( + tabId, + makeRpcSuccessResponse('stx_signMessage', { + id: requestId, + result: { signature: messageSignature.signature }, + }) + ); + closeWindow(); + }, + }); + + function onCancelMessageSigning() { + if (!requestId || !tabId) return; + void analytics.track('request_signature_cancel'); + chrome.tabs.sendMessage( + tabId, + makeRpcErrorResponse('stx_signMessage', { + id: requestId, + error: { + message: 'User denied signing', + code: RpcErrorCode.USER_REJECTION, + }, + }) + ); + closeWindow(); + } + + return { isLoading, signMessage, onCancelMessageSigning }; +} diff --git a/src/app/pages/sign-stacks-message-request/sign-stacks-message-request.tsx b/src/app/pages/sign-stacks-message-request/sign-stacks-message-request.tsx new file mode 100644 index 00000000000..1214ea51515 --- /dev/null +++ b/src/app/pages/sign-stacks-message-request/sign-stacks-message-request.tsx @@ -0,0 +1,31 @@ +import { isSignableMessageType } from '@shared/signature/signature-types'; + +import { StacksMessageSigning } from '@app/features/stacks-message-signer/stacks-message-signing'; +import { + useSignStacksMessageRequest, + useStacksMessageRequestPayload, +} from '@app/pages/sign-stacks-message-request/use-sign-stacks-message-request'; +import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks'; + +export function SignStacksMessageRequest() { + const { requestToken, messageType, tabId, origin } = useSignatureRequestSearchParams(); + const { isLoading, signMessage, onCancelMessageSigning } = useSignStacksMessageRequest(); + const payload = useStacksMessageRequestPayload(); + + if (!requestToken || !tabId) return null; + if (!isSignableMessageType(messageType)) return null; + if (!origin) return null; + if (!payload) return null; + + return ( + + ); +} diff --git a/src/app/pages/sign-stacks-message-request/use-sign-stacks-message-request.ts b/src/app/pages/sign-stacks-message-request/use-sign-stacks-message-request.ts new file mode 100644 index 00000000000..d260811062c --- /dev/null +++ b/src/app/pages/sign-stacks-message-request/use-sign-stacks-message-request.ts @@ -0,0 +1,67 @@ +import { finalizeMessageSignature } from '@shared/actions/finalize-message-signature'; +import { isStructuredMessageType, isUtf8MessageType } from '@shared/signature/signature-types'; + +import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; +import { + getSignaturePayloadFromToken, + getStructuredDataPayloadFromToken, +} from '@app/common/signature/requests'; +import { + StructuredPayload, + Utf8Payload, +} from '@app/features/stacks-message-signer/stacks-message-signing'; +import { useSignStacksMessage } from '@app/features/stacks-message-signer/use-sign-stacks-message'; +import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks'; + +export function useStacksMessageRequestPayload() { + const { requestToken, messageType } = useSignatureRequestSearchParams(); + + if (!requestToken) return null; + + if (isUtf8MessageType(messageType)) { + const signatureRequest = getSignaturePayloadFromToken(requestToken); + const { message, network } = signatureRequest; + const appName = signatureRequest.appDetails?.name; + return { + messageType: 'utf8' as const, + message, + network, + appName, + } satisfies Utf8Payload; + } + if (isStructuredMessageType(messageType)) { + const signatureRequest = getStructuredDataPayloadFromToken(requestToken); + const { message, network, domain } = signatureRequest; + const appName = signatureRequest.appDetails?.name; + return { + messageType: 'structured' as const, + message, + network, + appName, + domain, + } satisfies StructuredPayload; + } + return null; +} + +export function useSignStacksMessageRequest() { + const analytics = useAnalytics(); + + const { requestToken, tabId } = useSignatureRequestSearchParams(); + if (!tabId) throw new Error('Requests can only be made with corresponding tab'); + if (!requestToken) throw new Error('Missing request token'); + + const { isLoading, signMessage } = useSignStacksMessage({ + onSignMessageCompleted: messageSignature => { + finalizeMessageSignature({ requestPayload: requestToken, tabId, data: messageSignature }); + }, + }); + + function onCancelMessageSigning() { + if (!requestToken || !tabId) return; + void analytics.track('request_signature_cancel'); + finalizeMessageSignature({ requestPayload: requestToken, tabId, data: 'cancel' }); + } + + return { isLoading, signMessage, onCancelMessageSigning }; +} diff --git a/src/app/pages/stacks-message-signing-request/components/stacks-signature-message-content.tsx b/src/app/pages/stacks-message-signing-request/components/stacks-signature-message-content.tsx deleted file mode 100644 index 5cf11adc86f..00000000000 --- a/src/app/pages/stacks-message-signing-request/components/stacks-signature-message-content.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ChainID, bytesToHex } from '@stacks/common'; -import { hashMessage } from '@stacks/encryption'; - -import { getSignaturePayloadFromToken } from '@app/common/signature/requests'; -import { NoFeesWarningRow } from '@app/components/no-fees-warning-row'; - -import { MessagePreviewBox } from '../../../features/message-signer/message-preview-box'; -import { SignMessageActions } from '../../../features/message-signer/stacks-sign-message-action'; -import { useStacksMessageSigner } from '../stacks-message-signing.utils'; -import { StacksMessageSigningDisclaimer } from './message-signing-disclaimer'; - -interface SignatureRequestMessageContentProps { - requestToken: string; -} -export function StacksSignatureRequestMessageContent(props: SignatureRequestMessageContentProps) { - const { requestToken } = props; - const { isLoading, cancelMessageSigning, signMessage } = useStacksMessageSigner(); - const signatureRequest = getSignaturePayloadFromToken(requestToken); - const { message, network } = signatureRequest; - const appName = signatureRequest.appDetails?.name; - return ( - <> - - - signMessage({ messageType: 'utf8', message })} - /> -
- - - ); -} diff --git a/src/app/pages/stacks-message-signing-request/components/structured-data-content.tsx b/src/app/pages/stacks-message-signing-request/components/structured-data-content.tsx deleted file mode 100644 index 41e0b0a7c24..00000000000 --- a/src/app/pages/stacks-message-signing-request/components/structured-data-content.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ChainID } from '@stacks/common'; - -import { getStructuredDataPayloadFromToken } from '@app/common/signature/requests'; -import { NoFeesWarningRow } from '@app/components/no-fees-warning-row'; -import { SignMessageActions } from '@app/features/message-signer/stacks-sign-message-action'; - -import { useStacksMessageSigner } from '../stacks-message-signing.utils'; -import { StacksMessageSigningDisclaimer } from './message-signing-disclaimer'; -import { StructuredDataBox } from './structured-data-box'; - -interface SignatureRequestStructuredDataContentProps { - requestToken: string; -} -export function SignatureRequestStructuredDataContent({ - requestToken, -}: SignatureRequestStructuredDataContentProps) { - const { isLoading, signMessage, cancelMessageSigning } = useStacksMessageSigner(); - const signatureRequest = getStructuredDataPayloadFromToken(requestToken); - const { domain, message, network } = signatureRequest; - const appName = signatureRequest.appDetails?.name; - return ( - <> - - - signMessage({ messageType: 'structured', message, domain })} - /> -
- - - ); -} diff --git a/src/app/pages/stacks-message-signing-request/stacks-message-signing-request.tsx b/src/app/pages/stacks-message-signing-request/stacks-message-signing-request.tsx deleted file mode 100644 index 18eddaf83c6..00000000000 --- a/src/app/pages/stacks-message-signing-request/stacks-message-signing-request.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Outlet } from 'react-router-dom'; - -import { - isSignableMessageType, - isStructuredMessageType, - isUtf8MessageType, -} from '@shared/signature/signature-types'; -import { closeWindow } from '@shared/utils'; - -import { useRouteHeader } from '@app/common/hooks/use-route-header'; -import { PopupHeader } from '@app/features/current-account/popup-header'; -import { MessageSigningHeader } from '@app/features/message-signer/message-signing-header'; -import { useOnOriginTabClose } from '@app/routes/hooks/use-on-tab-closed'; -import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks'; - -import { MessageSigningRequestLayout } from '../../features/message-signer/message-signing-request.layout'; -import { StacksSignatureRequestMessageContent } from './components/stacks-signature-message-content'; -import { SignatureRequestStructuredDataContent } from './components/structured-data-content'; - -export function StacksMessageSigningRequest() { - useRouteHeader(); - useOnOriginTabClose(() => closeWindow()); - - const { requestToken, messageType, tabId, origin } = useSignatureRequestSearchParams(); - - if (!requestToken || !tabId) return null; - if (!isSignableMessageType(messageType)) return null; - if (!origin) return null; - - return ( - - - - {isUtf8MessageType(messageType) && ( - - )} - {isStructuredMessageType(messageType) && ( - - )} - - - ); -} diff --git a/src/app/pages/stacks-message-signing-request/stacks-message-signing.utils.ts b/src/app/pages/stacks-message-signing-request/stacks-message-signing.utils.ts deleted file mode 100644 index cee5549c071..00000000000 --- a/src/app/pages/stacks-message-signing-request/stacks-message-signing.utils.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { ClarityValue, TupleCV, createStacksPrivateKey } from '@stacks/transactions'; - -import { finalizeMessageSignature } from '@shared/actions/finalize-message-signature'; -import { signMessage, signStructuredDataMessage } from '@shared/crypto/sign-message'; -import { logger } from '@shared/logger'; -import { UnsignedMessage, whenSignableMessageOfType } from '@shared/signature/signature-types'; -import { isString } from '@shared/utils'; - -import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { useWalletType } from '@app/common/use-wallet-type'; -import { createDelay } from '@app/common/utils'; -import { useLedgerNavigate } from '@app/features/ledger/hooks/use-ledger-navigate'; -import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks'; - -const improveUxWithShortDelayAsSigningIsSoFast = createDelay(1000); - -function useMessageSignerStacksSoftwareWallet() { - const account = useCurrentStacksAccount(); - return useCallback( - ({ message, domain }: { message: string | ClarityValue; domain?: TupleCV }) => { - if (!account || account.type === 'ledger') return null; - const privateKey = createStacksPrivateKey(account.stxPrivateKey); - if (isString(message)) { - return signMessage(message, privateKey); - } else { - if (!domain) throw new Error('Domain is required for structured messages'); - // returns signature in RSV format - return signStructuredDataMessage(message, domain, privateKey); - } - }, - [account] - ); -} - -export function useStacksMessageSigner() { - const analytics = useAnalytics(); - const signSoftwareWalletMessage = useMessageSignerStacksSoftwareWallet(); - - const { whenWallet } = useWalletType(); - const ledgerNavigate = useLedgerNavigate(); - - const [isLoading, setIsLoading] = useState(false); - - const { requestToken, tabId } = useSignatureRequestSearchParams(); - if (!tabId) throw new Error('Requests can only be made with corresponding tab'); - if (!requestToken) throw new Error('Missing request token'); - - const signMessage = whenWallet({ - async software(unsignedMessage: UnsignedMessage) { - setIsLoading(true); - void analytics.track('request_signature_sign', { type: 'software' }); - - const messageSignature = signSoftwareWalletMessage(unsignedMessage); - - if (!messageSignature) { - logger.error('Cannot sign message, no account in state'); - void analytics.track('request_signature_cannot_sign_message_no_account'); - return; - } - await improveUxWithShortDelayAsSigningIsSoFast(); - setIsLoading(false); - finalizeMessageSignature({ requestPayload: requestToken, tabId, data: messageSignature }); - }, - - async ledger(unsignedMessage: UnsignedMessage) { - void analytics.track('request_signature_sign', { type: 'ledger' }); - whenSignableMessageOfType(unsignedMessage)({ - utf8(msg) { - ledgerNavigate.toConnectAndSignUtf8MessageStep(msg); - }, - structured(domain, msg) { - ledgerNavigate.toConnectAndSignStructuredMessageStep(domain, msg); - }, - }); - }, - }); - - function cancelMessageSigning() { - if (!requestToken || !tabId) return; - void analytics.track('request_signature_cancel'); - finalizeMessageSignature({ requestPayload: requestToken, tabId, data: 'cancel' }); - } - - return { isLoading, signMessage, cancelMessageSigning }; -} diff --git a/src/app/routes/request-routes.tsx b/src/app/routes/request-routes.tsx index 2551baaae4b..1b0e931009b 100644 --- a/src/app/routes/request-routes.tsx +++ b/src/app/routes/request-routes.tsx @@ -8,7 +8,7 @@ import { EditNonceDrawer } from '@app/features/edit-nonce-drawer/edit-nonce-draw import { ledgerStacksMessageSigningRoutes } from '@app/features/ledger/flows/stacks-message-signing/ledger-stacks-sign-msg.routes'; import { ledgerStacksTxSigningRoutes } from '@app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container'; import { PsbtRequest } from '@app/pages/psbt-request/psbt-request'; -import { StacksMessageSigningRequest } from '@app/pages/stacks-message-signing-request/stacks-message-signing-request'; +import { SignStacksMessageRequest } from '@app/pages/sign-stacks-message-request/sign-stacks-message-request'; import { TransactionRequest } from '@app/pages/transaction-request/transaction-request'; import { ProfileUpdateRequest } from '@app/pages/update-profile-request/update-profile-request'; import { AccountGate } from '@app/routes/account-gate'; @@ -35,7 +35,7 @@ export const legacyRequestRoutes = ( element={ }> - + } diff --git a/src/app/routes/rpc-routes.tsx b/src/app/routes/rpc-routes.tsx index 391fa035fd0..8893db05c47 100644 --- a/src/app/routes/rpc-routes.tsx +++ b/src/app/routes/rpc-routes.tsx @@ -1,14 +1,19 @@ +import { Suspense } from 'react'; import { Route } from 'react-router-dom'; import { RouteUrls } from '@shared/route-urls'; import { ledgerBitcoinTxSigningRoutes } from '@app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container'; +import { ledgerStacksMessageSigningRoutes } from '@app/features/ledger/flows/stacks-message-signing/ledger-stacks-sign-msg.routes'; 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 { RpcStacksMessageSigning } from '@app/pages/rpc-sign-stacks-message/rpc-sign-stacks-message'; import { AccountGate } from '@app/routes/account-gate'; +import { SuspenseLoadingSpinner } from './app-routes'; + export const rpcRequestRoutes = ( <> } /> + + }> + + + + } + > + {ledgerStacksMessageSigningRoutes} + ); diff --git a/src/app/store/signatures/requests.hooks.ts b/src/app/store/signatures/requests.hooks.ts index 5ec4ca0e599..793c55903ac 100644 --- a/src/app/store/signatures/requests.hooks.ts +++ b/src/app/store/signatures/requests.hooks.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { SignedMessageType } from '@shared/signature/signature-types'; -import { isString } from '@shared/utils'; import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; import { initialSearchParams } from '@app/common/initial-search-params'; @@ -16,7 +15,7 @@ export function useSignatureRequestSearchParams() { const messageType = initialSearchParams.get('messageType') as SignedMessageType; return { - tabId: isString(tabId) ? parseInt(tabId, 10) : tabId, + tabId, requestToken, origin, messageType, diff --git a/src/background/messaging/rpc-message-handler.ts b/src/background/messaging/rpc-message-handler.ts index 5856cc955d8..2d2efe85208 100644 --- a/src/background/messaging/rpc-message-handler.ts +++ b/src/background/messaging/rpc-message-handler.ts @@ -10,6 +10,7 @@ import { rpcGetAddresses } from './rpc-methods/get-addresses'; import { rpcSendTransfer } from './rpc-methods/send-transfer'; import { rpcSignMessage } from './rpc-methods/sign-message'; import { rpcSignPsbt } from './rpc-methods/sign-psbt'; +import { rpcSignStacksMessage } from './rpc-methods/sign-stacks-message'; import { rpcSupportedMethods } from './rpc-methods/supported-methods'; export async function rpcMessageHandler(message: WalletRequests, port: chrome.runtime.Port) { @@ -49,6 +50,11 @@ export async function rpcMessageHandler(message: WalletRequests, port: chrome.ru break; } + case 'stx_signMessage': { + await rpcSignStacksMessage(message, port); + break; + } + default: chrome.tabs.sendMessage( getTabIdFromPort(port), diff --git a/src/background/messaging/rpc-methods/sign-stacks-message.ts b/src/background/messaging/rpc-methods/sign-stacks-message.ts new file mode 100644 index 00000000000..dc63250f709 --- /dev/null +++ b/src/background/messaging/rpc-methods/sign-stacks-message.ts @@ -0,0 +1,78 @@ +import { RpcErrorCode } from '@btckit/types'; + +import { RouteUrls } from '@shared/route-urls'; +import { + SignStacksMessageRequest, + getRpcSignStacksMessageParamErrors, + validateRpcSignStacksMessageParams, +} from '@shared/rpc/methods/sign-stacks-message'; +import { makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; +import { isDefined, isUndefined } from '@shared/utils'; + +import { + RequestParams, + getTabIdFromPort, + listenForPopupClose, + makeSearchParamsWithDefaults, + triggerRequestWindowOpen, +} from '../messaging-utils'; + +export async function rpcSignStacksMessage( + message: SignStacksMessageRequest, + port: chrome.runtime.Port +) { + if (isUndefined(message.params)) { + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_signMessage', { + id: message.id, + error: { code: RpcErrorCode.INVALID_REQUEST, message: 'Parameters undefined' }, + }) + ); + return; + } + + if (!validateRpcSignStacksMessageParams(message.params)) { + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_signMessage', { + id: message.id, + error: { + code: RpcErrorCode.INVALID_PARAMS, + message: getRpcSignStacksMessageParamErrors(message.params), + }, + }) + ); + return; + } + + const requestParams: RequestParams = [ + ['message', message.params.message], + ['messageType', message.params.messageType], + ['requestId', message.id], + ]; + + if (isDefined(message.params.network)) { + requestParams.push(['network', message.params.network.toString()]); + } + + if (isDefined(message.params.domain)) { + requestParams.push(['domain', message.params.domain.toString()]); + } + + const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams); + + const { id } = await triggerRequestWindowOpen(RouteUrls.RpcStacksSignature, urlParams); + + listenForPopupClose({ + tabId, + id, + response: makeRpcErrorResponse('stx_signMessage', { + id: message.id, + error: { + code: RpcErrorCode.USER_REJECTION, + message: 'User rejected the Stacks message signing request', + }, + }), + }); +} diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index c2c9a64ff8f..f0b247bbea5 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -105,6 +105,7 @@ export enum RouteUrls { RpcSendTransferSummary = '/send-transfer/summary', RpcReceiveBitcoinContractOffer = '/bitcoin-contract-offer/:bitcoinContractOffer/:counterpartyWalletURL', RpcSignBip322Message = '/sign-bip322-message', + RpcStacksSignature = '/sign-stacks-message', // Shared legacy and rpc request routes RequestError = '/request-error', diff --git a/src/shared/rpc/methods/sign-stacks-message.ts b/src/shared/rpc/methods/sign-stacks-message.ts new file mode 100644 index 00000000000..7dab65cfb3b --- /dev/null +++ b/src/shared/rpc/methods/sign-stacks-message.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 SignedMessageTypeArray = ['utf8', 'structured'] as const; + +const rpcSignStacksMessageParamsSchema = yup.object().shape({ + network: yup.string().oneOf(StacksNetworks), + message: yup.string().required(), + domain: yup.string(), + messageType: yup.string().oneOf(SignedMessageTypeArray).required(), +}); + +export function validateRpcSignStacksMessageParams(obj: unknown) { + return validateRpcParams(obj, rpcSignStacksMessageParamsSchema); +} + +export function getRpcSignStacksMessageParamErrors(obj: unknown) { + return formatValidationErrors(getRpcParamErrors(obj, rpcSignStacksMessageParamsSchema)); +} + +type SignStacksMessageRequestParams = yup.InferType; + +export type SignStacksMessageRequest = RpcRequest< + 'stx_signMessage', + SignStacksMessageRequestParams +>; + +type SignStacksMessageResponse = RpcResponse<{ signature: string }>; + +export type SignStacksMessage = DefineRpcMethod< + SignStacksMessageRequest, + SignStacksMessageResponse +>; diff --git a/src/shared/rpc/rpc-methods.ts b/src/shared/rpc/rpc-methods.ts index 7384565e5c7..74460b67f7a 100644 --- a/src/shared/rpc/rpc-methods.ts +++ b/src/shared/rpc/rpc-methods.ts @@ -5,6 +5,7 @@ import { ValueOf } from '@shared/utils/type-utils'; import { AcceptBitcoinContract } from './methods/accept-bitcoin-contract'; import { SignPsbt } from './methods/sign-psbt'; +import { SignStacksMessage } from './methods/sign-stacks-message'; import { SupportedMethods } from './methods/supported-methods'; // Supports BtcKit methods, as well as custom Leather methods @@ -12,7 +13,8 @@ export type WalletMethodMap = BtcKitMethodMap & SupportedMethods & SignPsbt & AcceptBitcoinContract & - SignStacksTransaction; + SignStacksTransaction & + SignStacksMessage; export type WalletRequests = ValueOf['request']; export type WalletResponses = ValueOf['response']; diff --git a/test-app/src/components/signature.tsx b/test-app/src/components/signature.tsx index 6999a1667eb..c0a3e89816d 100644 --- a/test-app/src/components/signature.tsx +++ b/test-app/src/components/signature.tsx @@ -23,6 +23,7 @@ import { noneCV, responseErrorCV, responseOkCV, + serializeCV, someCV, standardPrincipalCV, stringAsciiCV, @@ -122,6 +123,17 @@ export const Signature = () => { }, }); }; + + const signMessageRpc = async (message: string) => { + clearState(); + setCurrentMessage(message); + + await window.btc?.request('stx_signMessage', { + message, + messageType: 'utf8', + }); + }; + const domain = tupleCV({ name: stringAsciiCV('hiro.so'), version: stringAsciiCV('1.0.0'), @@ -149,6 +161,21 @@ export const Signature = () => { }); }; + const signStructureRpc = async (message: ClarityValue, domain: TupleCV) => { + clearState(); + setCurrentStructuredData({ message, domain }); + + // ClarityValue -> Uint8Array -> Buffer -> string (hex) + const stringMessage = Buffer.from(serializeCV(message)).toString('hex'); + const stringDomain = Buffer.from(serializeCV(domain)).toString('hex'); + + await window.btc?.request('stx_signMessage', { + message: stringMessage, + messageType: 'structured', + domain: stringDomain, + }); + }; + const sip18Test = [ { message: stringAsciiCV('Hello World'), @@ -204,6 +231,17 @@ export const Signature = () => { expected hash : '1bfdab6d4158313ce34073fbb8d6b0fc32c154d439def12247a0f44bb2225259' +
+
+ RPC +
+ signMessageRpc(signatureMessage)}> + Signature RPC (Testnet) + +
+ signStructureRpc(structuredData, domain)}> + Signature Structure RPC (Testnet) + ); };