diff --git a/src/app/pages/rpc-get-xpub/components/get-xpub.layout.tsx b/src/app/pages/rpc-get-xpub/components/get-xpub.layout.tsx new file mode 100644 index 00000000000..b2329960aa7 --- /dev/null +++ b/src/app/pages/rpc-get-xpub/components/get-xpub.layout.tsx @@ -0,0 +1,54 @@ +import { Box, Flex, styled } from 'leather-styles/jsx'; + +import { RequesterFlag } from '@app/components/requester-flag'; +import { Button } from '@app/ui/components/button/button'; +import { LettermarkIcon } from '@app/ui/icons'; +import { LogomarkIcon } from '@app/ui/icons/logomark-icon'; + +interface GetXpubLayoutProps { + requester: string; + onUserApproveGetXpub(): void; +} +export function GetXpubLayout(props: GetXpubLayoutProps) { + const { requester, onUserApproveGetXpub } = props; + + return ( + + + + + + Connect your account to + + + + + + + + + By connecting you give permission to {requester} to view Xpub of this account + + + + ); +} diff --git a/src/app/pages/rpc-get-xpub/rpc-get-xpub.tsx b/src/app/pages/rpc-get-xpub/rpc-get-xpub.tsx new file mode 100644 index 00000000000..6dc910fcabb --- /dev/null +++ b/src/app/pages/rpc-get-xpub/rpc-get-xpub.tsx @@ -0,0 +1,17 @@ +import { closeWindow } from '@shared/utils'; + +import { GetXpubLayout } from './components/get-xpub.layout'; +import { useGetXpub } from './use-request-accounts'; + +export function RpcGetXpub() { + const { origin, onUserApproveGetXpub } = useGetXpub(); + + if (origin === null) { + closeWindow(); + throw new Error('Origin is null'); + } + + const requester = new URL(origin).host; + + return ; +} diff --git a/src/app/pages/rpc-get-xpub/use-request-accounts.ts b/src/app/pages/rpc-get-xpub/use-request-accounts.ts new file mode 100644 index 00000000000..dcd4f083fc4 --- /dev/null +++ b/src/app/pages/rpc-get-xpub/use-request-accounts.ts @@ -0,0 +1,58 @@ +import { deriveNativeSegwitAccountFromRootKeychain } from '@shared/crypto/bitcoin/p2wpkh-address-gen'; +import { logger } from '@shared/logger'; +import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; +import { closeWindow } from '@shared/utils'; + +import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; +import { mnemonicToRootNode } from '@app/common/keychain/keychain'; +import { useRpcRequestParams } from '@app/common/rpc-helpers'; +import { useCurrentAccountIndex } from '@app/store/accounts/account'; +import { useAppPermissions } from '@app/store/app-permissions/app-permissions.slice'; +import { useDefaultWalletSecretKey } from '@app/store/in-memory-key/in-memory-key.selectors'; + +export function useGetXpub() { + const analytics = useAnalytics(); + + const permissions = useAppPermissions(); + const { tabId, origin, requestId } = useRpcRequestParams(); + + const currentAccountIndex = useCurrentAccountIndex(); + const secretKey = useDefaultWalletSecretKey(); + const rootKey = secretKey ? mnemonicToRootNode(secretKey) : null; + + return { + origin, + onUserApproveGetXpub() { + if (!tabId || !origin) { + logger.error('Cannot give app accounts: missing tabId, origin'); + return; + } + + const keysToIncludeInResponse = []; + + if (rootKey) { + const createBitcoinAccount = deriveNativeSegwitAccountFromRootKeychain(rootKey, 'mainnet'); + const currentBitcoinAccount = createBitcoinAccount(currentAccountIndex); + + const nativeSegwitXpubResponse: any = { + symbol: 'BTC', + type: 'p2wpkh', + xpub: currentBitcoinAccount.keychain.publicExtendedKey, + }; + + keysToIncludeInResponse.push(nativeSegwitXpubResponse); + } + + void analytics.track('user_approved_get_xpub', { origin }); + permissions.hasRequestedAccounts(origin); + chrome.tabs.sendMessage( + tabId, + makeRpcSuccessResponse('getXpub', { + id: requestId, + result: { xpubs: keysToIncludeInResponse as any }, + }) + ); + closeWindow(); + }, + }; +} diff --git a/src/app/routes/rpc-routes.tsx b/src/app/routes/rpc-routes.tsx index 8893db05c47..eb722e8bd2d 100644 --- a/src/app/routes/rpc-routes.tsx +++ b/src/app/routes/rpc-routes.tsx @@ -6,6 +6,7 @@ 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 { RpcGetXpub } from '@app/pages/rpc-get-xpub/rpc-get-xpub'; 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'; @@ -24,6 +25,14 @@ export const rpcRequestRoutes = ( } /> + + + + } + /> {rpcSendTransferRoutes} ; + +type GetXpubResponse = RpcResponse<{ xpubs: [] }>; + +export type GetXpub = DefineRpcMethod; diff --git a/src/shared/rpc/rpc-methods.ts b/src/shared/rpc/rpc-methods.ts index 74460b67f7a..282177d46d0 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 { GetXpub } from '@shared/rpc/methods/get-xpub'; import { SignStacksTransaction } from '@shared/rpc/methods/sign-stacks-transaction'; import { ValueOf } from '@shared/utils/type-utils'; @@ -14,7 +15,8 @@ export type WalletMethodMap = BtcKitMethodMap & SignPsbt & AcceptBitcoinContract & SignStacksTransaction & - SignStacksMessage; + SignStacksMessage & + GetXpub; export type WalletRequests = ValueOf['request']; export type WalletResponses = ValueOf['response']; diff --git a/tests/mocks/constants.ts b/tests/mocks/constants.ts index af9b99da5a2..8075a29b554 100644 --- a/tests/mocks/constants.ts +++ b/tests/mocks/constants.ts @@ -11,6 +11,10 @@ export const TEST_ACCOUNT_1_STX_ADDRESS = 'SPS8CKF63P16J28AYF7PXW9E5AACH0NZNTEFW export const TEST_ACCOUNT_2_STX_ADDRESS = 'SPXH3HNBPM5YP15VH16ZXZ9AX6CK289K3MCXRKCB'; export const TEST_TESTNET_ACCOUNT_2_STX_ADDRESS = 'STXH3HNBPM5YP15VH16ZXZ9AX6CK289K3NVR9T1P'; +// Account extended public keys +export const TEST_ACCOUNT_1_XPUB = + 'xpub6BuKrNqTrGfsy8VAAdUW2KCxbHywuSKjg7hZuAXERXDv7GfuxUgUWdVRKNsgujcwdjEHCjaXWouPKi1m5gMgdWX8JpRcyMkrSxPe4Da3Lx8'; + // Account public keys export const TEST_ACCOUNT_1_PUBKEY = '02b6b0afe5f620bc8e532b640b148dd9dea0ed19d11f8ab420fcce488fe3974893'; diff --git a/tests/specs/rpc-get-xpub/get-xpub.spec.ts b/tests/specs/rpc-get-xpub/get-xpub.spec.ts new file mode 100644 index 00000000000..1ee28b4141d --- /dev/null +++ b/tests/specs/rpc-get-xpub/get-xpub.spec.ts @@ -0,0 +1,43 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { TEST_ACCOUNT_1_XPUB } from '@tests/mocks/constants'; + +import { test } from '../../fixtures/fixtures'; + +test.describe('RPC get Xpub', () => { + test.beforeEach(async ({ extensionId, globalPage, onboardingPage, page }) => { + await globalPage.setupAndUseApiCalls(extensionId); + await onboardingPage.signInWithTestAccount(extensionId); + await page.goto('localhost:3000', { waitUntil: 'networkidle' }); + }); + + function checkVisibleContent(context: BrowserContext) { + return async (buttonToPress: 'Cancel' | 'Confirm') => { + const popup = await context.waitForEvent('page'); + await popup.waitForTimeout(500); + const btn = popup.locator('text="Connect Leather"'); + + if (buttonToPress === 'Confirm') { + await btn.click(); + } else { + await popup.close(); + } + }; + } + + function initiateRPCGetXpub(page: Page) { + return async () => + page.evaluate(async () => + (window as any).LeatherProvider.request('getXpub').catch((e: unknown) => e) + ); + } + + test('that xpub is correct', async ({ page, context }) => { + const xpub = TEST_ACCOUNT_1_XPUB; + const [result] = await Promise.all([ + initiateRPCGetXpub(page)(), + checkVisibleContent(context)('Confirm'), + ]); + + test.expect(result.result.xpubs[0].xpub).toEqual(xpub); + }); +});