mirror of
https://github.com/zhigang1992/wallet.git
synced 2026-04-22 11:37:33 +08:00
@@ -97,7 +97,7 @@
|
||||
"@sentry/tracing": "6.16.1",
|
||||
"@stacks/blockchain-api-client": "0.65.0",
|
||||
"@stacks/common": "4.0.0",
|
||||
"@stacks/connect": "6.5.0",
|
||||
"@stacks/connect": "6.6.0",
|
||||
"@stacks/connect-ui": "5.5.0",
|
||||
"@stacks/encryption": "4.0.0",
|
||||
"@stacks/network": "4.0.0",
|
||||
@@ -148,6 +148,7 @@
|
||||
"react-router-dom": "6.2.1",
|
||||
"react-virtuoso": "2.6.0",
|
||||
"redux-persist": "6.0.0",
|
||||
"sha.js": "2.4.11",
|
||||
"svg-url-loader": "7.1.1",
|
||||
"ts-debounce": "4",
|
||||
"use-events": "1.4.2",
|
||||
@@ -168,8 +169,7 @@
|
||||
"@emotion/cache": "11.7.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.4",
|
||||
"@schemastore/web-manifest": "0.0.5",
|
||||
"@stacks/auth": "4.0.0",
|
||||
"@stacks/connect-react": "14.0.0",
|
||||
"@stacks/connect-react": "15.0.0",
|
||||
"@stacks/eslint-config": "1.0.10",
|
||||
"@stacks/prettier-config": "0.0.10",
|
||||
"@stacks/stacks-blockchain-api-types": "0.65.0",
|
||||
@@ -270,10 +270,11 @@
|
||||
"**/**/xmldom": "github:xmldom/xmldom#0.7.0",
|
||||
"**/**/@stacks/network": "4.0.0",
|
||||
"@redux-devtools/cli/**/tar": "4.4.18",
|
||||
"@types/react": "17.0.37",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"async": "2.6.4",
|
||||
"bn.js": "5.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"css-what": "5.0.1",
|
||||
"dot-prop": "6.0.1",
|
||||
"hosted-git-info": "4.0.2",
|
||||
"immer": "9.0.6",
|
||||
|
||||
27
src/app/common/actions/finalize-message-signature.ts
Normal file
27
src/app/common/actions/finalize-message-signature.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ExternalMethods, MESSAGE_SOURCE, SignatureResponseMessage } from '@shared/message-types';
|
||||
import { logger } from '@shared/logger';
|
||||
import { SignatureData } from '@shared/crypto/sign-message';
|
||||
|
||||
export const finalizeMessageSignature = (
|
||||
requestPayload: string,
|
||||
tabId: number,
|
||||
data: SignatureData | string
|
||||
) => {
|
||||
try {
|
||||
const responseMessage: SignatureResponseMessage = {
|
||||
source: MESSAGE_SOURCE,
|
||||
method: ExternalMethods.signatureResponse,
|
||||
payload: {
|
||||
signatureRequest: requestPayload,
|
||||
signatureResponse: data,
|
||||
},
|
||||
};
|
||||
chrome.tabs.sendMessage(tabId, responseMessage);
|
||||
window.close();
|
||||
} catch (error) {
|
||||
logger.debug('Failed to get Tab ID for message signature request:', requestPayload);
|
||||
throw new Error(
|
||||
'Your message was signed, but we lost communication with the app you started with.'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export enum LoadingKeys {
|
||||
EDIT_NONCE_DRAWER = 'loading/EDIT_NONCE_DRAWER',
|
||||
INCREASE_FEE_DRAWER = 'loading/INCREASE_FEE_DRAWER',
|
||||
SUBMIT_TRANSACTION = 'loading/SUBMIT_TRANSACTION',
|
||||
SUBMIT_SIGNATURE = 'loading/SUBMIT_SIGNATURE',
|
||||
}
|
||||
|
||||
export function useLoading(key: string) {
|
||||
|
||||
79
src/app/common/signature/requests.ts
Normal file
79
src/app/common/signature/requests.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Account, getAppPrivateKey } from '@stacks/wallet-sdk';
|
||||
import { SignaturePayload } from '@stacks/connect';
|
||||
import { decodeToken, TokenVerifier } from 'jsontokens';
|
||||
import { getPublicKeyFromPrivate } from '@stacks/encryption';
|
||||
import { getAddressFromPrivateKey, TransactionVersion } from '@stacks/transactions';
|
||||
|
||||
export function getPayloadFromToken(requestToken: string) {
|
||||
const token = decodeToken(requestToken);
|
||||
return token.payload as unknown as SignaturePayload;
|
||||
}
|
||||
|
||||
function getTransactionVersionFromRequest(signature: SignaturePayload) {
|
||||
const { network } = signature;
|
||||
if (!network) return TransactionVersion.Mainnet;
|
||||
if (![TransactionVersion.Mainnet, TransactionVersion.Testnet].includes(network.version)) {
|
||||
throw new Error('Invalid network version provided');
|
||||
}
|
||||
return network.version;
|
||||
}
|
||||
|
||||
const UNAUTHORIZED_SIGNATURE_REQUEST =
|
||||
'The signature request provided is not signed by this wallet.';
|
||||
/**
|
||||
* Verify a transaction request.
|
||||
* A transaction request is a signed JWT that is created on an app,
|
||||
* via `@stacks/connect`. The private key used to sign this JWT is an
|
||||
* `appPrivateKey`, which an app can get from authentication.
|
||||
*
|
||||
* The payload in this JWT can include an `stxAddress`. This indicates the
|
||||
* 'default' STX address that should be used to sign this transaction. This allows
|
||||
* the wallet to use the same account to sign a transaction as it used to sign
|
||||
* in to the app.
|
||||
*
|
||||
* This JWT is invalidated if:
|
||||
* - The JWT is not signed properly
|
||||
* - The public key used to sign this tx request does not match an `appPrivateKey`
|
||||
* for any of the accounts in this wallet.
|
||||
* - The `stxAddress` provided in the payload does not match an STX address
|
||||
* for any of the accounts in this wallet.
|
||||
*
|
||||
* @returns The decoded and validated `SignaturePayload`
|
||||
* @throws if the transaction request is invalid
|
||||
*/
|
||||
interface VerifySignatureRequestArgs {
|
||||
requestToken: string;
|
||||
accounts: Account[];
|
||||
appDomain: string;
|
||||
}
|
||||
export async function verifySignatureRequest({
|
||||
requestToken,
|
||||
accounts,
|
||||
appDomain,
|
||||
}: VerifySignatureRequestArgs): Promise<SignaturePayload> {
|
||||
const token = decodeToken(requestToken);
|
||||
const signature = token.payload as unknown as SignaturePayload;
|
||||
const { publicKey, stxAddress } = signature;
|
||||
const txVersion = getTransactionVersionFromRequest(signature);
|
||||
const verifier = new TokenVerifier('ES256k', publicKey);
|
||||
const isSigned = await verifier.verifyAsync(requestToken);
|
||||
if (!isSigned) {
|
||||
throw new Error('Signature request is not signed');
|
||||
}
|
||||
const foundAccount = accounts.find(account => {
|
||||
const appPrivateKey = getAppPrivateKey({
|
||||
account,
|
||||
appDomain,
|
||||
});
|
||||
const appPublicKey = getPublicKeyFromPrivate(appPrivateKey);
|
||||
if (appPublicKey !== publicKey) return false;
|
||||
if (!stxAddress) return true;
|
||||
const accountStxAddress = getAddressFromPrivateKey(account.stxPrivateKey, txVersion);
|
||||
if (stxAddress !== accountStxAddress) return false;
|
||||
return true;
|
||||
});
|
||||
if (!foundAccount) {
|
||||
throw new Error(UNAUTHORIZED_SIGNATURE_REQUEST);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
@@ -234,10 +234,15 @@ export function getUrlHostname(url: string) {
|
||||
return new URL(url).hostname;
|
||||
}
|
||||
|
||||
export function getUrlPort(url: string) {
|
||||
function getUrlPort(url: string) {
|
||||
return new URL(url).port;
|
||||
}
|
||||
|
||||
export function addPortSuffix(url: string) {
|
||||
const port = getUrlPort(url);
|
||||
return port ? `:${port}` : '';
|
||||
}
|
||||
|
||||
export async function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { CurrentAccountAvatar } from '@app/features/current-account/current-acco
|
||||
import { CurrentAccountName } from '@app/features/current-account/current-account-name';
|
||||
import { CurrentStxAddress } from '@app/features/current-account/current-stx-address';
|
||||
|
||||
import { Balance } from './balance';
|
||||
import { useCurrentAccount } from '@app/store/accounts/account.hooks';
|
||||
import { Balance } from '@app/components/balance';
|
||||
|
||||
function PopupHeaderSuspense(): JSX.Element {
|
||||
const account = useCurrentAccount();
|
||||
60
src/app/pages/signature-request/components/hash-drawer.tsx
Normal file
60
src/app/pages/signature-request/components/hash-drawer.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Stack, Flex, Box, Text } from '@stacks/ui';
|
||||
import { FiChevronDown, FiChevronUp } from 'react-icons/fi';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ShowHashButtonProps {
|
||||
expanded: boolean;
|
||||
}
|
||||
const ShowHashButton = (props: ShowHashButtonProps) => {
|
||||
const { expanded } = props;
|
||||
return <Box as={expanded ? FiChevronUp : FiChevronDown} size="16px" />;
|
||||
};
|
||||
|
||||
interface HashDrawerProps {
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export function HashDrawer(props: HashDrawerProps): JSX.Element | null {
|
||||
const { hash } = props;
|
||||
const [showHash, setShowHash] = useState(false);
|
||||
const [displayHash, setDisplayHash] = useState(hash);
|
||||
return (
|
||||
<Stack px="loose" spacing="loose">
|
||||
<Flex marginBottom="0px !important">
|
||||
<Text display="block" fontSize={1} py="tight">
|
||||
{showHash ? 'Hide hash' : 'Show hash'}
|
||||
</Text>
|
||||
<Box
|
||||
_hover={{ cursor: 'pointer' }}
|
||||
marginLeft={'auto'}
|
||||
marginTop={'auto'}
|
||||
marginBottom={'auto'}
|
||||
onClick={() => {
|
||||
setDisplayHash(showHash ? '' : hash);
|
||||
setShowHash(!showHash);
|
||||
}}
|
||||
>
|
||||
<ShowHashButton expanded={showHash} />
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box
|
||||
transition="all 0.65s cubic-bezier(0.23, 1, 0.32, 1)"
|
||||
height={showHash ? '100%' : '0'}
|
||||
visibility={showHash ? 'visible' : 'hidden'}
|
||||
>
|
||||
<Stack spacing="base-tight">
|
||||
<Text
|
||||
display="block"
|
||||
color="#74777D"
|
||||
fontSize={1}
|
||||
lineHeight="1.6"
|
||||
wordBreak="break-all"
|
||||
fontFamily={'Fira Code'}
|
||||
>
|
||||
{displayHash}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
46
src/app/pages/signature-request/components/message-box.tsx
Normal file
46
src/app/pages/signature-request/components/message-box.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { color, Stack, Text } from '@stacks/ui';
|
||||
import { sha256 } from 'sha.js';
|
||||
import { HashDrawer } from './hash-drawer';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface MessageBoxProps {
|
||||
message: string;
|
||||
}
|
||||
export function MessageBox(props: MessageBoxProps): JSX.Element | null {
|
||||
const { message } = props;
|
||||
const [hash, setHash] = useState<string | undefined>();
|
||||
useEffect(() => {
|
||||
setHash(new sha256().update(message).digest('hex'));
|
||||
}, [message]);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack minHeight={'260px'}>
|
||||
<Stack
|
||||
border="4px solid"
|
||||
paddingBottom={'8px'}
|
||||
borderColor={color('border')}
|
||||
borderRadius="20px"
|
||||
backgroundColor={color('border')}
|
||||
>
|
||||
<Stack
|
||||
py="loose"
|
||||
px="loose"
|
||||
spacing="loose"
|
||||
borderRadius="16px"
|
||||
backgroundColor={'white'}
|
||||
>
|
||||
<Stack spacing="base-tight">
|
||||
<Text display="block" fontSize={2} lineHeight="1.6" wordBreak="break-all">
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{hash ? <HashDrawer hash={hash} /> : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/app/pages/signature-request/components/network-row.tsx
Normal file
31
src/app/pages/signature-request/components/network-row.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { whenChainId } from '@app/common/transactions/transaction-utils';
|
||||
import { SpaceBetween } from '@app/components/space-between';
|
||||
import { Caption } from '@app/components/typography';
|
||||
import { StacksNetwork } from '@stacks/network';
|
||||
import { ChainID } from '@stacks/transactions';
|
||||
import { Box } from '@stacks/ui';
|
||||
|
||||
interface NetworkRowProps {
|
||||
network: StacksNetwork;
|
||||
}
|
||||
export function NetworkRow(props: NetworkRowProps): JSX.Element | null {
|
||||
const { network } = props;
|
||||
|
||||
return (
|
||||
<Box spacing="base">
|
||||
<SpaceBetween position="relative">
|
||||
<Box alignItems="center" isInline>
|
||||
<Caption>No fees will be incured</Caption>
|
||||
</Box>
|
||||
<Caption>
|
||||
<span>
|
||||
{whenChainId(network.chainId)({
|
||||
[ChainID.Testnet]: 'Testnet',
|
||||
[ChainID.Mainnet]: 'Mainnet',
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
</SpaceBetween>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
34
src/app/pages/signature-request/components/page-top.tsx
Normal file
34
src/app/pages/signature-request/components/page-top.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import { Stack } from '@stacks/ui';
|
||||
|
||||
import { useCurrentNetwork } from '@app/common/hooks/use-current-network';
|
||||
import { addPortSuffix, getUrlHostname } from '@app/common/utils';
|
||||
import { Caption, Title } from '@app/components/typography';
|
||||
import { getPayloadFromToken } from '@app/common/signature/requests';
|
||||
import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks';
|
||||
|
||||
function PageTopBase(): JSX.Element | null {
|
||||
const network = useCurrentNetwork();
|
||||
const { origin, requestToken } = useSignatureRequestSearchParams();
|
||||
if (!requestToken) return null;
|
||||
const signatureRequest = getPayloadFromToken(requestToken);
|
||||
if (!signatureRequest) return null;
|
||||
|
||||
const appName = signatureRequest?.appDetails?.name;
|
||||
const originAddition = origin ? ` (${getUrlHostname(origin)})` : '';
|
||||
const testnetAddition = network.isTestnet
|
||||
? ` using ${getUrlHostname(network.url)}${addPortSuffix(network.url)}`
|
||||
: '';
|
||||
const caption = appName ? `Requested by "${appName}"${originAddition}${testnetAddition}` : null;
|
||||
|
||||
return (
|
||||
<Stack pt="extra-loose" spacing="base">
|
||||
<Title fontWeight="bold" as="h1">
|
||||
Sign Message
|
||||
</Title>
|
||||
{caption && <Caption wordBreak="break-word">{caption}</Caption>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const PageTop = memo(PageTopBase);
|
||||
67
src/app/pages/signature-request/components/sign-action.tsx
Normal file
67
src/app/pages/signature-request/components/sign-action.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { finalizeMessageSignature } from '@app/common/actions/finalize-message-signature';
|
||||
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
|
||||
import { delay } from '@app/common/utils';
|
||||
import { useCurrentAccount } from '@app/store/accounts/account.hooks';
|
||||
import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks';
|
||||
import { signMessage } from '@shared/crypto/sign-message';
|
||||
import { logger } from '@shared/logger';
|
||||
import { createStacksPrivateKey } from '@stacks/transactions';
|
||||
import { Button, Stack } from '@stacks/ui';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
function useSignMessageSoftwareWallet() {
|
||||
const account = useCurrentAccount();
|
||||
return useCallback(
|
||||
(message: string) => {
|
||||
if (!account) return null;
|
||||
const privateKey = createStacksPrivateKey(account.stxPrivateKey);
|
||||
return signMessage(message, privateKey);
|
||||
},
|
||||
[account]
|
||||
);
|
||||
}
|
||||
|
||||
interface SignActionProps {
|
||||
message: string;
|
||||
}
|
||||
export function SignAction(props: SignActionProps): JSX.Element | null {
|
||||
const { message } = props;
|
||||
const signSoftwareWalletMessage = useSignMessageSoftwareWallet();
|
||||
const { tabId, requestToken } = useSignatureRequestSearchParams();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const analytics = useAnalytics();
|
||||
|
||||
if (!requestToken || !tabId) return null;
|
||||
const tabIdInt = parseInt(tabId);
|
||||
|
||||
const sign = async () => {
|
||||
setIsLoading(true);
|
||||
void analytics.track('request_signature_sign');
|
||||
const messageSignature = signSoftwareWalletMessage(message);
|
||||
if (!messageSignature) {
|
||||
logger.error('Cannot sign message, no account in state');
|
||||
void analytics.track('request_signature_cannot_sign_message_no_account');
|
||||
return;
|
||||
}
|
||||
// Since the signature is really fast, we add a delay to improve the UX
|
||||
await delay(1000);
|
||||
setIsLoading(false);
|
||||
finalizeMessageSignature(requestToken, tabIdInt, messageSignature);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
void analytics.track('request_signature_cancel');
|
||||
finalizeMessageSignature(requestToken, tabIdInt, 'cancel');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack isInline>
|
||||
<Button onClick={cancel} flexGrow={1} borderRadius="10px" mode="tertiary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" flexGrow={1} borderRadius="10px" onClick={sign} isLoading={isLoading}>
|
||||
Sign
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
111
src/app/pages/signature-request/signature-request.tsx
Normal file
111
src/app/pages/signature-request/signature-request.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { memo } from 'react';
|
||||
import { Box, color, Stack } from '@stacks/ui';
|
||||
|
||||
import { useRouteHeader } from '@app/common/hooks/use-route-header';
|
||||
|
||||
import {
|
||||
useIsSignatureRequestValid,
|
||||
useSignatureRequestSearchParams,
|
||||
} from '@app/store/signatures/requests.hooks';
|
||||
import { PageTop } from './components/page-top';
|
||||
import { MessageBox } from './components/message-box';
|
||||
import { NetworkRow } from './components/network-row';
|
||||
import { SignAction } from './components/sign-action';
|
||||
import { StacksNetwork, StacksTestnet } from '@stacks/network';
|
||||
import { FiAlertTriangle } from 'react-icons/fi';
|
||||
import { Caption } from '@app/components/typography';
|
||||
import { PopupHeader } from '@app/features/current-account/popup-header';
|
||||
import { getPayloadFromToken } from '@app/common/signature/requests';
|
||||
import { isUndefined } from '@app/common/utils';
|
||||
import { Link } from '@app/components/link';
|
||||
import { openInNewTab } from '@app/common/utils/open-in-new-tab';
|
||||
|
||||
function SignatureRequestBase(): JSX.Element | null {
|
||||
const validSignatureRequest = useIsSignatureRequestValid();
|
||||
const { requestToken } = useSignatureRequestSearchParams();
|
||||
useRouteHeader(<PopupHeader />);
|
||||
|
||||
if (!requestToken) return null;
|
||||
const signatureRequest = getPayloadFromToken(requestToken);
|
||||
if (!signatureRequest) return null;
|
||||
if (isUndefined(validSignatureRequest)) return null;
|
||||
const appName = signatureRequest?.appDetails?.name;
|
||||
const { message, network } = signatureRequest;
|
||||
|
||||
return (
|
||||
<Stack px={['loose', 'unset']} spacing="loose" width="100%">
|
||||
<PageTop />
|
||||
{!validSignatureRequest ? (
|
||||
<ErrorMessage errorMessage={'Invalid signature request'} />
|
||||
) : (
|
||||
<SignatureRequestContent
|
||||
message={message}
|
||||
network={network || new StacksTestnet()}
|
||||
appName={appName}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DisclaimerProps {
|
||||
appName: string | undefined;
|
||||
}
|
||||
|
||||
function Disclaimer(props: DisclaimerProps) {
|
||||
const { appName } = props;
|
||||
return (
|
||||
<Box>
|
||||
<Caption>
|
||||
By signing this message, you are authorizing {appName || 'the app'} to do something specific
|
||||
on your behalf. Only sign messages that you understand from apps that you trust.
|
||||
<Link
|
||||
display={'inline'}
|
||||
fontSize="14px"
|
||||
onClick={() => openInNewTab('https://docs.hiro.so/build-apps/message-signing')}
|
||||
>
|
||||
{' '}
|
||||
Learn more
|
||||
</Link>
|
||||
.
|
||||
</Caption>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SignatureRequestContentProps {
|
||||
network: StacksNetwork;
|
||||
message: string;
|
||||
appName: string | undefined;
|
||||
}
|
||||
|
||||
function SignatureRequestContent(props: SignatureRequestContentProps) {
|
||||
const { message, network, appName } = props;
|
||||
return (
|
||||
<>
|
||||
<MessageBox message={message} />
|
||||
<NetworkRow network={network} />
|
||||
<SignAction message={message} />
|
||||
<hr />
|
||||
<Disclaimer appName={appName} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const SignatureRequest = memo(SignatureRequestBase);
|
||||
|
||||
interface ErrorMessageProps {
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
function ErrorMessage(props: ErrorMessageProps): JSX.Element | null {
|
||||
const { errorMessage } = props;
|
||||
if (!errorMessage) return null;
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" bg="#FCEEED" p="base" borderRadius="12px" isInline>
|
||||
<Box color={color('feedback-error')} strokeWidth={2} as={FiAlertTriangle} />
|
||||
<Caption color={color('feedback-error')}>{errorMessage}</Caption>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,13 @@ import { memo } from 'react';
|
||||
import { Stack } from '@stacks/ui';
|
||||
|
||||
import { useCurrentNetwork } from '@app/common/hooks/use-current-network';
|
||||
import { getUrlHostname, getUrlPort } from '@app/common/utils';
|
||||
import { addPortSuffix, getUrlHostname } from '@app/common/utils';
|
||||
import { Caption, Title } from '@app/components/typography';
|
||||
import { usePageTitle } from '@app/pages/transaction-request/hooks/use-page-title';
|
||||
import { useTransactionRequestState } from '@app/store/transactions/requests.hooks';
|
||||
import { useOrigin } from '@app/store/transactions/requests.hooks';
|
||||
import { TransactionSigningSelectors } from '@tests/page-objects/transaction-signing.selectors';
|
||||
|
||||
function addPortSuffix(url: string) {
|
||||
const port = getUrlPort(url);
|
||||
return port ? `:${port}` : '';
|
||||
}
|
||||
|
||||
function PageTopBase(): JSX.Element | null {
|
||||
const transactionRequest = useTransactionRequestState();
|
||||
const origin = useOrigin();
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useFeeSchema } from '@app/common/validation/use-fee-schema';
|
||||
import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
|
||||
import { useNextTxNonce } from '@app/common/hooks/account/use-next-tx-nonce';
|
||||
import { HighFeeDrawer } from '@app/features/high-fee-drawer/high-fee-drawer';
|
||||
import { PopupHeader } from '@app/pages/transaction-request/components/popup-header';
|
||||
import { PageTop } from '@app/pages/transaction-request/components/page-top';
|
||||
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';
|
||||
@@ -31,6 +30,7 @@ import { SubmitAction } from './components/submit-action';
|
||||
import { useUnsignedTransactionFee } from './hooks/use-signed-transaction-fee';
|
||||
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
|
||||
import { Estimations } from '@shared/models/fees-types';
|
||||
import { PopupHeader } from '@app/features/current-account/popup-header';
|
||||
|
||||
function TransactionRequestBase(): JSX.Element | null {
|
||||
useNextTxNonce();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LoadingSpinner } from '@app/components/loading-spinner';
|
||||
import { MagicRecoveryCode } from '@app/pages/onboarding/magic-recovery-code/magic-recovery-code';
|
||||
import { ChooseAccount } from '@app/pages/choose-account/choose-account';
|
||||
import { TransactionRequest } from '@app/pages/transaction-request/transaction-request';
|
||||
import { SignatureRequest } from '@app/pages/signature-request/signature-request';
|
||||
import { SignIn } from '@app/pages/onboarding/sign-in/sign-in';
|
||||
import { ReceiveTokens } from '@app/pages/receive-tokens/receive-tokens';
|
||||
import { AddNetwork } from '@app/pages/add-network/add-network';
|
||||
@@ -147,6 +148,16 @@ export function AppRoutes(): JSX.Element | null {
|
||||
}
|
||||
/>
|
||||
<Route path={RouteUrls.UnauthorizedRequest} element={<UnauthorizedRequest />} />
|
||||
<Route
|
||||
path={RouteUrls.SignatureRequest}
|
||||
element={
|
||||
<AccountGate>
|
||||
<Suspense fallback={<LoadingSpinner height="600px" />}>
|
||||
<SignatureRequest />
|
||||
</Suspense>
|
||||
</AccountGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={RouteUrls.ViewSecretKey}
|
||||
element={
|
||||
|
||||
38
src/app/store/signatures/requests.hooks.ts
Normal file
38
src/app/store/signatures/requests.hooks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { verifySignatureRequest } from '@app/common/signature/requests';
|
||||
import { useMemo } from 'react';
|
||||
import { useAsync } from 'react-async-hook';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAccounts } from '../accounts/account.hooks';
|
||||
|
||||
export function useIsSignatureRequestValid() {
|
||||
const accounts = useAccounts();
|
||||
const { origin, requestToken } = useSignatureRequestSearchParams();
|
||||
|
||||
return useAsync(async () => {
|
||||
if (typeof accounts === 'undefined') return;
|
||||
if (!origin || !accounts || !requestToken) return;
|
||||
try {
|
||||
const valid = await verifySignatureRequest({
|
||||
requestToken,
|
||||
accounts,
|
||||
appDomain: origin,
|
||||
});
|
||||
return !!valid;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}, [accounts, requestToken, origin]).result;
|
||||
}
|
||||
|
||||
export function useSignatureRequestSearchParams() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
requestToken: searchParams.get('request'),
|
||||
tabId: searchParams.get('tabId'),
|
||||
origin: searchParams.get('origin'),
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,23 @@ chrome.runtime.onConnect.addListener(port =>
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ExternalMethods.signatureRequest: {
|
||||
const path = RouteUrls.SignatureRequest;
|
||||
const urlParams = new URLSearchParams();
|
||||
if (!port.sender) return;
|
||||
const { tab, url } = port.sender;
|
||||
if (!tab?.id || !url) return;
|
||||
const origin = new URL(url).origin;
|
||||
urlParams.set('request', payload);
|
||||
urlParams.set('tabId', tab.id.toString());
|
||||
urlParams.set('origin', origin);
|
||||
if (IS_TEST_ENV) {
|
||||
await openRequestInFullPage(path, urlParams);
|
||||
} else {
|
||||
popupCenter({ url: `/popup-center.html#${path}?${urlParams.toString()}` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ExternalMethods.transactionRequest: {
|
||||
void storePayload({
|
||||
payload,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
AuthenticationRequestEvent,
|
||||
DomEventName,
|
||||
SignatureRequestEvent,
|
||||
TransactionRequestEvent,
|
||||
} from '@shared/inpage-types';
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
@@ -94,6 +95,16 @@ document.addEventListener(DomEventName.transactionRequest, ((event: TransactionR
|
||||
});
|
||||
}) as EventListener);
|
||||
|
||||
// Listen for a CustomEvent (signature request) coming from the web app
|
||||
document.addEventListener(DomEventName.signatureRequest, ((event: SignatureRequestEvent) => {
|
||||
forwardDomEventToBackground({
|
||||
path: RouteUrls.SignatureRequest,
|
||||
payload: event.detail.signatureRequest,
|
||||
urlParam: 'request',
|
||||
method: ExternalMethods.signatureRequest,
|
||||
});
|
||||
}) as EventListener);
|
||||
|
||||
// Inject inpage script (Stacks Provider)
|
||||
const inpage = document.createElement('script');
|
||||
inpage.src = chrome.runtime.getURL('inpage.js');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { StacksProvider } from '@stacks/connect';
|
||||
import {
|
||||
AuthenticationRequestEventDetails,
|
||||
DomEventName,
|
||||
SignatureRequestEventDetails,
|
||||
TransactionRequestEventDetails,
|
||||
} from '@shared/inpage-types';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
ExternalMethods,
|
||||
MessageToContentScript,
|
||||
MESSAGE_SOURCE,
|
||||
SignatureResponseMessage,
|
||||
TransactionResponseMessage,
|
||||
} from '@shared/message-types';
|
||||
import { logger } from '@shared/logger';
|
||||
@@ -65,6 +67,27 @@ const provider: StacksProvider = {
|
||||
const { url } = await callAndReceive('getURL');
|
||||
return url;
|
||||
},
|
||||
signatureRequest: async signatureRequest => {
|
||||
const event = new CustomEvent<SignatureRequestEventDetails>(DomEventName.signatureRequest, {
|
||||
detail: { signatureRequest },
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleMessage = (event: MessageEvent<SignatureResponseMessage>) => {
|
||||
if (!isValidEvent(event, ExternalMethods.signatureResponse)) return;
|
||||
if (event.data.payload?.signatureRequest !== signatureRequest) return;
|
||||
window.removeEventListener('message', handleMessage);
|
||||
if (event.data.payload.signatureResponse === 'cancel') {
|
||||
reject(event.data.payload.signatureResponse);
|
||||
return;
|
||||
}
|
||||
if (typeof event.data.payload.signatureResponse !== 'string') {
|
||||
resolve(event.data.payload.signatureResponse);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
});
|
||||
},
|
||||
authenticationRequest: async authenticationRequest => {
|
||||
const event = new CustomEvent<AuthenticationRequestEventDetails>(
|
||||
DomEventName.authenticationRequest,
|
||||
|
||||
13
src/shared/crypto/sign-message.ts
Normal file
13
src/shared/crypto/sign-message.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { signECDSA, hashMessage } from '@stacks/encryption';
|
||||
import { StacksPrivateKey } from '@stacks/transactions';
|
||||
|
||||
export interface SignatureData {
|
||||
signature: string; // - Hex encoded DER signature
|
||||
publicKey: string; // - Hex encoded private string taken from privateKey
|
||||
}
|
||||
|
||||
export function signMessage(message: string, privateKey: StacksPrivateKey): SignatureData {
|
||||
const privateKeyUncompressed = privateKey.data.slice(0, 32);
|
||||
const hash = hashMessage(message);
|
||||
return signECDSA(privateKeyUncompressed.toString('hex'), hash);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
export enum DomEventName {
|
||||
authenticationRequest = 'stacksAuthenticationRequest',
|
||||
signatureRequest = 'signatureRequest',
|
||||
transactionRequest = 'stacksTransactionRequest',
|
||||
}
|
||||
|
||||
@@ -12,6 +13,12 @@ export interface AuthenticationRequestEventDetails {
|
||||
|
||||
export type AuthenticationRequestEvent = CustomEvent<AuthenticationRequestEventDetails>;
|
||||
|
||||
export interface SignatureRequestEventDetails {
|
||||
signatureRequest: string;
|
||||
}
|
||||
|
||||
export type SignatureRequestEvent = CustomEvent<SignatureRequestEventDetails>;
|
||||
|
||||
export interface TransactionRequestEventDetails {
|
||||
transactionRequest: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FinishedTxPayload, SponsoredFinishedTxPayload } from '@stacks/connect';
|
||||
import { SignatureData } from './crypto/sign-message';
|
||||
|
||||
export const MESSAGE_SOURCE = 'stacks-wallet' as const;
|
||||
|
||||
@@ -9,6 +10,8 @@ export enum ExternalMethods {
|
||||
transactionResponse = 'transactionResponse',
|
||||
authenticationRequest = 'authenticationRequest',
|
||||
authenticationResponse = 'authenticationResponse',
|
||||
signatureRequest = 'signatureRequest',
|
||||
signatureResponse = 'signatureResponse',
|
||||
}
|
||||
|
||||
export enum InternalMethods {
|
||||
@@ -44,6 +47,16 @@ export type AuthenticationResponseMessage = Message<
|
||||
}
|
||||
>;
|
||||
|
||||
type SignatureRequestMessage = Message<ExternalMethods.signatureRequest, string>;
|
||||
|
||||
export type SignatureResponseMessage = Message<
|
||||
ExternalMethods.signatureResponse,
|
||||
{
|
||||
signatureRequest: string;
|
||||
signatureResponse: SignatureData | string;
|
||||
}
|
||||
>;
|
||||
|
||||
type TransactionRequestMessage = Message<ExternalMethods.transactionRequest, string>;
|
||||
|
||||
export type TxResult = SponsoredFinishedTxPayload | FinishedTxPayload;
|
||||
@@ -56,5 +69,11 @@ export type TransactionResponseMessage = Message<
|
||||
}
|
||||
>;
|
||||
|
||||
export type MessageFromContentScript = AuthenticationRequestMessage | TransactionRequestMessage;
|
||||
export type MessageToContentScript = AuthenticationResponseMessage | TransactionResponseMessage;
|
||||
export type MessageFromContentScript =
|
||||
| AuthenticationRequestMessage
|
||||
| TransactionRequestMessage
|
||||
| SignatureRequestMessage;
|
||||
export type MessageToContentScript =
|
||||
| AuthenticationResponseMessage
|
||||
| TransactionResponseMessage
|
||||
| SignatureResponseMessage;
|
||||
|
||||
@@ -20,4 +20,5 @@ export enum RouteUrls {
|
||||
ViewSecretKey = '/view-secret-key',
|
||||
// Locked wallet route
|
||||
Unlock = '/unlock',
|
||||
SignatureRequest = '/signature',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const userHasAllowedDiagnosticsKey = 'stacks-wallet-has-allowed-diagnosti
|
||||
export enum StorageKey {
|
||||
'authenticationRequests',
|
||||
'transactionRequests',
|
||||
'signatureRequests',
|
||||
}
|
||||
|
||||
interface RequestInfo {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RPCClient } from '@stacks/rpc-client';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { StacksTestnet } from '@stacks/network';
|
||||
import { StacksMainnet, StacksTestnet } from '@stacks/network';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -16,4 +16,6 @@ export const toRelativeTime = (ts: number): string => dayjs().to(ts);
|
||||
|
||||
export const stacksTestnetNetwork = new StacksTestnet({ url: testnetUrl });
|
||||
|
||||
export const stacksMainnetNetwork = new StacksMainnet();
|
||||
|
||||
export const stacksLocalhostNetwork = new StacksTestnet({ url: localhostUrl });
|
||||
|
||||
@@ -56,6 +56,7 @@ export const Debugger = () => {
|
||||
setTxType(type);
|
||||
};
|
||||
|
||||
|
||||
// If need to add more test tokens: STW7PFH79HW1C9Z0SXBP5PTPHKZZ58KK9WP1MZZA
|
||||
const handleSponsoredTransactionBroadcast = async (tx: StacksTransaction) => {
|
||||
const sponsorOptions = {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Status } from './status';
|
||||
import { Counter } from './counter';
|
||||
import { Debugger } from './debugger';
|
||||
import { Bns } from './bns';
|
||||
import { Signature } from './signature';
|
||||
|
||||
type Tabs = 'status' | 'counter' | 'debug' | 'bns';
|
||||
type Tabs = 'status' | 'counter' | 'debug' | 'bns' | 'signature';
|
||||
|
||||
const Container: React.FC<BoxProps> = ({ children, ...props }) => {
|
||||
return (
|
||||
@@ -37,6 +38,9 @@ const Page: React.FC<{ tab: Tabs; setTab: (value: Tabs) => void }> = ({ tab, set
|
||||
<Tab active={tab === 'bns'}>
|
||||
<Text onClick={() => setTab('bns')}>BNS</Text>
|
||||
</Tab>
|
||||
<Tab active={tab === 'signature'}>
|
||||
<Text onClick={() => setTab('signature')}>Signature</Text>
|
||||
</Tab>
|
||||
</Flex>
|
||||
</Container>
|
||||
<Container>
|
||||
@@ -44,6 +48,7 @@ const Page: React.FC<{ tab: Tabs; setTab: (value: Tabs) => void }> = ({ tab, set
|
||||
{tab === 'counter' && <Counter />}
|
||||
{tab === 'debug' && <Debugger />}
|
||||
{tab === 'bns' && <Bns />}
|
||||
{tab === 'signature' && <Signature />}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
60
test-app/src/components/signature.tsx
Normal file
60
test-app/src/components/signature.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Button, Text } from '@stacks/ui';
|
||||
import { SignatureData, useConnect } from '@stacks/connect-react';
|
||||
import { verifyECDSA, hashMessage } from '@stacks/encryption';
|
||||
import { stacksTestnetNetwork as network } from '@common/utils';
|
||||
|
||||
export const Signature = () => {
|
||||
const [signature, setSignature] = useState<SignatureData | undefined>();
|
||||
const [signatureIsVerified, setSignatureIsVerified] = useState<boolean | undefined>();
|
||||
const [currentMessage, setCurrentMessage] = useState<string | undefined>();
|
||||
const signatureMessage = 'Hello world!';
|
||||
const longSignatureMessage = 'Nullam eu ante vel est convallis dignissim. Fusce suscipit, wisi nec facilisis facilisis, est dui fermentum leo, quis tempor ligula erat quis odio. Nunc porta vulputate tellus. Nunc rutrum turpis sed pede. Sed bibendum. Aliquam posuere. Nunc aliquet, augue nec adipiscing interdum, lacus tellus malesuada massa, quis varius mi purus non odio. Pellentesque condimentum, magna ut suscipit hendrerit, ipsum augue ornare nulla, non luctus diam neque sit amet urna. Curabitur vulputate vestibulum lorem. Fusce sagittis, libero non molestie mollis, magna orci ultrices dolor, at vulputate neque nulla lacinia eros. Sed id ligula quis est convallis tempor. Curabitur lacinia pulvinar nibh. Nam a sapien.'
|
||||
const { sign } = useConnect();
|
||||
|
||||
useEffect(() => {
|
||||
if (!signature || !currentMessage) return;
|
||||
const verified = verifyECDSA(hashMessage(currentMessage), signature.publicKey, signature.signature);
|
||||
setSignatureIsVerified(verified);
|
||||
}, [signature, currentMessage])
|
||||
|
||||
const clearState = () => {
|
||||
setSignatureIsVerified(undefined);
|
||||
setSignature(undefined);
|
||||
};
|
||||
|
||||
const signMessage = async (message: string) => {
|
||||
clearState();
|
||||
setCurrentMessage(message);
|
||||
await sign ({
|
||||
/* network: stacksMainnetNetwork, */
|
||||
network,
|
||||
message,
|
||||
onFinish: (sigObj: SignatureData) => {
|
||||
console.log('signature from debugger', sigObj);
|
||||
setSignature(sigObj);
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('popup closed!');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box py={6}>
|
||||
|
||||
{ signature && (
|
||||
<Text textStyle="body.large" display="block" my={'base'}>
|
||||
<Text color="green" fontSize={1}> Signature {signatureIsVerified ? 'successfully ' : 'not'} verified</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Button mt={3} onClick={() => signMessage(signatureMessage)}>
|
||||
Signature
|
||||
</Button>
|
||||
<br />
|
||||
<Button mt={3} onClick={() => signMessage(longSignatureMessage)}>
|
||||
Signature (long message)
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -76,7 +76,12 @@ const HTML_PROD_OPTIONS = IS_DEV
|
||||
},
|
||||
};
|
||||
|
||||
const aliases = {};
|
||||
const aliases = {
|
||||
'@stacks/network/dist/polyfill': '@stacks/network/dist/esm',
|
||||
'@stacks/network': '@stacks/network/dist/esm',
|
||||
'@stacks/profile': '@stacks/profile/dist/esm',
|
||||
'@stacks/auth': '@stacks/auth/dist/esm',
|
||||
};
|
||||
|
||||
const config = {
|
||||
entry: { index: path.join(SRC_ROOT_PATH, 'index.tsx') },
|
||||
|
||||
@@ -79,6 +79,7 @@ const aliases = {
|
||||
'@stacks/storage': '@stacks/storage/dist/esm',
|
||||
'@stacks/transactions': '@stacks/transactions/dist/esm',
|
||||
'@stacks/wallet-sdk': '@stacks/wallet-sdk/dist/esm',
|
||||
'@stacks/network/dist/polyfill': '@stacks/network/dist/esm',
|
||||
};
|
||||
|
||||
const config = {
|
||||
|
||||
Reference in New Issue
Block a user