From b9b64a493bdac193285a6c027f375862550c6a69 Mon Sep 17 00:00:00 2001 From: siandreev Date: Fri, 7 Feb 2025 21:35:29 +0700 Subject: [PATCH] feat: import wallet by sk --- packages/core/src/entries/account.ts | 50 ++++- packages/core/src/entries/dashboard.ts | 1 + packages/core/src/entries/password.ts | 2 +- packages/core/src/service/accountsStorage.ts | 26 ++- packages/core/src/service/mnemonicService.ts | 61 +++++- packages/core/src/service/passwordService.ts | 17 +- packages/core/src/service/walletService.ts | 70 ++++++- packages/locales/src/tonkeeper-web/en.json | 4 + packages/locales/src/tonkeeper-web/ru-RU.json | 4 + .../src/components/account/AccountBadge.tsx | 2 +- .../components/create/ChoseWalletVersions.tsx | 33 +++- .../uikit/src/components/create/SKInput.tsx | 83 ++++++++ .../desktop/aside/AsideMenuAccount.tsx | 7 +- .../settings/DesktopManageWalletsSettings.tsx | 4 +- .../src/pages/import/ImportBySKWallet.tsx | 182 ++++++++++++++++++ .../src/pages/import/ImportExistingWallet.tsx | 4 +- .../src/pages/import/ImportTestnetWallet.tsx | 6 +- packages/uikit/src/pages/settings/Dev.tsx | 20 ++ .../uikit/src/pages/settings/Recovery.tsx | 60 ++++-- packages/uikit/src/state/mnemonic.ts | 86 ++++++--- packages/uikit/src/state/wallet.ts | 123 ++++++++++-- 21 files changed, 741 insertions(+), 104 deletions(-) create mode 100644 packages/uikit/src/components/create/SKInput.tsx create mode 100644 packages/uikit/src/pages/import/ImportBySKWallet.tsx diff --git a/packages/core/src/entries/account.ts b/packages/core/src/entries/account.ts index 7878e47f5..cb241d19f 100644 --- a/packages/core/src/entries/account.ts +++ b/packages/core/src/entries/account.ts @@ -189,6 +189,28 @@ export class AccountTonTestnet extends TonMnemonic { } } +export class AccountTonSK extends TonMnemonic { + public readonly type = 'sk'; + + static create(params: { + id: AccountId; + name: string; + emoji: string; + auth: AuthPassword | AuthKeychain; + activeTonWalletId: WalletId; + tonWallets: TonWalletStandard[]; + }) { + return new AccountTonSK( + params.id, + params.name, + params.emoji, + params.auth, + params.activeTonWalletId, + params.tonWallets + ); + } +} + export class AccountTonWatchOnly extends Clonable implements IAccount { public readonly type = 'watch-only'; @@ -655,7 +677,11 @@ export class AccountTonMultisig extends Clonable implements IAccount { } } -export type AccountVersionEditable = AccountTonMnemonic | AccountTonOnly | AccountTonTestnet; +export type AccountVersionEditable = + | AccountTonMnemonic + | AccountTonOnly + | AccountTonTestnet + | AccountTonSK; export type AccountTonWalletStandard = | AccountVersionEditable @@ -670,6 +696,7 @@ export function isAccountVersionEditable(account: Account): account is AccountVe case 'mnemonic': case 'ton-only': case 'testnet': + case 'sk': return true; case 'ledger': case 'keystone': @@ -690,6 +717,7 @@ export function isAccountTonWalletStandard(account: Account): account is Account case 'ton-only': case 'mam': case 'testnet': + case 'sk': return true; case 'watch-only': case 'ton-multisig': @@ -705,6 +733,7 @@ export function isAccountCanManageMultisigs(account: Account): boolean { case 'ton-only': case 'mam': case 'ledger': + case 'sk': return true; case 'watch-only': case 'ton-multisig': @@ -723,6 +752,7 @@ export function isMnemonicAndPassword( case 'mam': case 'mnemonic': case 'testnet': + case 'sk': return true; case 'ton-only': case 'ledger': @@ -746,6 +776,7 @@ export function getNetworkByAccount(account: Account): Network { case 'watch-only': case 'ton-multisig': case 'keystone': + case 'sk': return Network.MAINNET; default: assertUnreachable(account); @@ -770,6 +801,7 @@ export function isAccountTronCompatible( case 'watch-only': case 'ton-multisig': case 'keystone': + case 'sk': return false; default: return assertUnreachable(account); @@ -787,6 +819,7 @@ export function isAccountBip39(account: Account) { case 'watch-only': case 'ton-multisig': case 'keystone': + case 'sk': return false; default: return assertUnreachable(account); @@ -809,7 +842,8 @@ const prototypes = { 'ton-only': AccountTonOnly.prototype, 'watch-only': AccountTonWatchOnly.prototype, mam: AccountMAM.prototype, - 'ton-multisig': AccountTonMultisig.prototype + 'ton-multisig': AccountTonMultisig.prototype, + sk: AccountTonSK.prototype } as const; export function bindAccountToClass(accountStruct: Account): void { @@ -844,3 +878,15 @@ export type AccountsFolderStored = { name: string; lastIsOpened: boolean; }; + +export type AccountSecretMnemonic = { + type: 'mnemonic'; + mnemonic: string[]; +}; + +export type AccountSecretSK = { + type: 'sk'; + sk: string; +}; + +export type AccountSecret = AccountSecretMnemonic | AccountSecretSK; diff --git a/packages/core/src/entries/dashboard.ts b/packages/core/src/entries/dashboard.ts index f69ee10b0..80cc79849 100644 --- a/packages/core/src/entries/dashboard.ts +++ b/packages/core/src/entries/dashboard.ts @@ -141,6 +141,7 @@ function accountAndWalletToString(account: Account, walletId: WalletId): string case 'ton-only': case 'mnemonic': case 'testnet': + case 'sk': if (account.allTonWallets.length === 1) { return baseInfo; } diff --git a/packages/core/src/entries/password.ts b/packages/core/src/entries/password.ts index bd5c9da78..6e6d8922e 100644 --- a/packages/core/src/entries/password.ts +++ b/packages/core/src/entries/password.ts @@ -12,7 +12,7 @@ export type MnemonicType = 'ton' | 'bip39'; export interface AuthPassword { kind: 'password'; - encryptedMnemonic: string; + encryptedSecret: string; } export interface AuthKeychain { diff --git a/packages/core/src/service/accountsStorage.ts b/packages/core/src/service/accountsStorage.ts index 4a09a0055..c6ba1707b 100644 --- a/packages/core/src/service/accountsStorage.ts +++ b/packages/core/src/service/accountsStorage.ts @@ -37,6 +37,8 @@ export class AccountsStorage { } else { state.forEach(bindAccountToClass); } + + await this.migrateAccountSecret(state); return state ?? defaultAccountState; }; @@ -190,6 +192,28 @@ export class AccountsStorage { )?.id || null ); }; + + private migrateAccountSecret = async (accounts: Account[] | null) => { + if (!accounts) { + return; + } + let needUpdate = false; + + accounts.forEach(account => { + if ('auth' in account && account.auth.kind === 'password') { + if ((account.auth as unknown as { encryptedMnemonic: string }).encryptedMnemonic) { + account.auth.encryptedSecret = ( + account.auth as unknown as { encryptedMnemonic: string } + ).encryptedMnemonic; + needUpdate = true; + } + } + }); + + if (needUpdate) { + await this.setAccounts(accounts); + } + }; } export const accountsStorage = (storage: IStorage): AccountsStorage => new AccountsStorage(storage); @@ -237,7 +261,7 @@ async function migrateToAccountsState(storage: IStorage): Promise { - const mnemonic = (await decrypt(state.auth.encryptedMnemonic, password)).split(' '); - const isValid = await seeIfMnemonicValid(mnemonic); - if (!isValid) { - throw new Error('Wallet mnemonic not valid'); +export const decryptWalletSecret = async ( + encryptedSecret: string, + password: string +): Promise => { + const secret = await decrypt(encryptedSecret, password); + return walletSecretFromString(secret); + + throw new Error('Wallet secret not valid'); +}; + +export const walletSecretFromString = async (secret: string): Promise => { + const isValidMnemonic = await seeIfMnemonicValid(secret.split(' ')); + if (isValidMnemonic) { + return { + type: 'mnemonic', + mnemonic: secret.split(' ') + }; } - return mnemonic; + + if (isValidSK(secret)) { + return { + type: 'sk', + sk: secret + }; + } + + throw new Error('Wallet secret not valid'); +}; + +export const walletSecretToString = (secret: AccountSecret): string => { + if (secret.type === 'mnemonic') { + return secret.mnemonic.join(' '); + } + + if (secret.type === 'sk') { + return secret.sk; + } + + assertUnreachable(secret); +}; + +export const encryptWalletSecret = async ( + secret: AccountSecret, + password: string +): Promise => { + const stringSecret = walletSecretToString(secret); + return encrypt(stringSecret, password); +}; + +export const isValidSK = (sk: string) => { + return /^[0-9a-fA-F]{128}$/.test(sk); }; export const seeIfMnemonicValid = async (mnemonic: string[]) => { diff --git a/packages/core/src/service/passwordService.ts b/packages/core/src/service/passwordService.ts index 520f4aada..7eec4003b 100644 --- a/packages/core/src/service/passwordService.ts +++ b/packages/core/src/service/passwordService.ts @@ -2,8 +2,7 @@ import { IStorage } from '../Storage'; import { AccountTonMnemonic, isMnemonicAndPassword } from '../entries/account'; import { AuthPassword } from '../entries/password'; import { AccountsStorage } from './accountsStorage'; -import { decrypt, encrypt } from './cryptoService'; -import { decryptWalletMnemonic, seeIfMnemonicValid } from './mnemonicService'; +import { decryptWalletSecret, encryptWalletSecret } from './mnemonicService'; export class PasswordStorage { private readonly accountsStorage: AccountsStorage; @@ -24,10 +23,8 @@ export class PasswordStorage { throw new Error('None wallet has a password auth'); } - const mnemonic = ( - await decrypt((accToCheck.auth as AuthPassword).encryptedMnemonic, password) - ).split(' '); - return await seeIfMnemonicValid(mnemonic); + await decryptWalletSecret((accToCheck.auth as AuthPassword).encryptedSecret, password); + return true; } catch (e) { console.error(e); return false; @@ -46,12 +43,12 @@ export class PasswordStorage { const updatedAccounts = await Promise.all( accounts.map(async acc => { - const mnemonic = await decryptWalletMnemonic( - acc as { auth: AuthPassword }, + const accountSecret = await decryptWalletSecret( + (acc.auth as AuthPassword).encryptedSecret, oldPassword ); - (acc.auth as AuthPassword).encryptedMnemonic = await encrypt( - mnemonic.join(' '), + (acc.auth as AuthPassword).encryptedSecret = await encryptWalletSecret( + accountSecret, newPassword ); return acc.clone(); diff --git a/packages/core/src/service/walletService.ts b/packages/core/src/service/walletService.ts index bb222d26c..fc3a6118b 100644 --- a/packages/core/src/service/walletService.ts +++ b/packages/core/src/service/walletService.ts @@ -11,31 +11,32 @@ import { AccountMAM, AccountTonMnemonic, AccountTonMultisig, - AccountTonOnly, + AccountTonOnly, AccountTonSK, AccountTonTestnet, AccountTonWatchOnly -} from '../entries/account'; +} from "../entries/account"; import { APIConfig } from '../entries/apis'; import { Network } from '../entries/network'; import { AuthKeychain, AuthPassword, MnemonicType } from '../entries/password'; import { - WalletVersion, - WalletVersions, - TonWalletStandard, DerivationItemNamed, + sortWalletsByVersion, + TonWalletStandard, WalletId, - sortWalletsByVersion + WalletVersion, + WalletVersions } from '../entries/wallet'; import { AccountsApi, WalletApi } from '../tonApiV2'; import { emojis } from '../utils/emojis'; import { accountsStorage } from './accountsStorage'; import { walletContract } from './wallet/contractService'; -import { TonKeychainRoot, KeychainTonAccount } from '@ton-keychain/core'; +import { KeychainTonAccount, TonKeychainRoot } from '@ton-keychain/core'; import { mnemonicToKeypair } from './mnemonicService'; import { FiatCurrencies } from '../entries/fiat'; import { KeychainTrxAccountsProvider, TronAddressUtils } from '@ton-keychain/trx'; import { TronWallet } from '../entries/tron/tron-wallet'; import { ethers } from 'ethers'; +import { keyPairFromSecretKey } from '@ton/crypto'; export const createMultisigTonAccount = async ( storage: IStorage, @@ -161,7 +162,7 @@ export const getContextApiByNetwork = (context: CreateWalletContext, network: Ne return api; }; -const createPayloadOfStandardTonAccount = async ( +const ]createPayloadOfStandardTonAccount = async ( appContext: CreateWalletContext, storage: IStorage, mnemonic: string[], @@ -264,6 +265,59 @@ export const createStandardTonAccountByMnemonic = async ( }); }; +export const createStandardTonAccountBySK = async ( + appContext: CreateWalletContext, + storage: IStorage, + sk: string, + options: { + versions?: WalletVersion[]; + auth: AuthPassword | Omit; + } +) => { + const keyPair = keyPairFromSecretKey(Buffer.from(sk, 'hex')); + + const publicKey = keyPair.publicKey.toString('hex'); + + let tonWallets: { rawAddress: string; version: WalletVersion }[] = []; + if (options.versions) { + tonWallets = options.versions + .map(v => getWalletAddress(publicKey, v, Network.MAINNET)) + .map(i => ({ + rawAddress: i.address.toRawString(), + version: i.version + })); + } else { + tonWallets = [await findWalletAddress(appContext, Network.MAINNET, publicKey)]; + } + + let walletAuth: AuthPassword | AuthKeychain; + if (options.auth.kind === 'keychain') { + walletAuth = { + kind: 'keychain', + keychainStoreKey: publicKey + }; + } else { + walletAuth = options.auth; + } + + const { name, emoji } = await accountsStorage(storage).getNewAccountNameAndEmoji(publicKey); + + const wallets = tonWallets + .slice() + .map(item => getTonWalletStandard(item, publicKey, Network.MAINNET)); + + const walletIdToActivate = wallets.slice().sort(sortWalletsByVersion)[0].id; + + return AccountTonSK.create({ + id: createAccountId(Network.MAINNET, publicKey), + name, + emoji, + auth: walletAuth, + activeTonWalletId: walletIdToActivate, + tonWallets: wallets + }); +}; + export const standardTonAccountToAccountWithTron = async ( account: AccountTonMnemonic, getAccountMnemonic: () => Promise diff --git a/packages/locales/src/tonkeeper-web/en.json b/packages/locales/src/tonkeeper-web/en.json index 6cff98d37..9fa267865 100644 --- a/packages/locales/src/tonkeeper-web/en.json +++ b/packages/locales/src/tonkeeper-web/en.json @@ -10,6 +10,7 @@ "actionTitle": "Open the wallet", "activate": "Activate", "add": "Add", + "add_by_sk_title": "Account by private key", "add_dns_address": "Add wallet address that domain %1% will link to.", "add_wallet_existing_multisig_description": "%{number} wallets managed by your wallet list", "add_wallet_existing_multisig_title": "Existing Multisig Wallet", @@ -333,6 +334,7 @@ "settings_multisig_settings": "Multisig Settings", "show": "Show", "show_all": "Show All", + "sk_input_label": "Private key in hex format without 0x beginning. Length is 128 chars", "start_trial_notification_description": "Telegram connection is required solely for the purpose of verification that you are not a bot.", "start_trial_notification_heading": "Connect Telegram to Pro for Free", "swap_balance": "Balance", @@ -422,6 +424,7 @@ "txActions_USDT_transfer": "USDT Transfer", "Undo": "Undo", "Unexpected_QR_Code": "Unexpected QR Code", + "unknown_operation": "Unknown Operation", "Unlock": "Unlock", "update": "Update", "update_ledger_error": "Make sure your Ledger software is updated", @@ -433,6 +436,7 @@ "wallet_aside_collectibles": "Collectibles", "wallet_aside_domains": "Domains", "wallet_aside_history": "History", + "wallet_aside_home": "Home", "wallet_aside_multisig_wallets": "Multisig Wallets", "wallet_aside_orders": "Requests", "wallet_aside_purchases": "Purchases", diff --git a/packages/locales/src/tonkeeper-web/ru-RU.json b/packages/locales/src/tonkeeper-web/ru-RU.json index b074ff67c..c52cf6f7f 100644 --- a/packages/locales/src/tonkeeper-web/ru-RU.json +++ b/packages/locales/src/tonkeeper-web/ru-RU.json @@ -10,6 +10,7 @@ "actionTitle": "Открыть Кошелек", "activate": "Активировать", "add": "Добавить", + "add_by_sk_title": "Аккаунт по приватному ключу", "add_dns_address": "Добавьте адрес кошелька, на который будет ссылаться домен %1%", "add_wallet_existing_multisig_description": "%{number} кошельков управляются вашим списком кошельков", "add_wallet_existing_multisig_title": "Существующий Multisig кошелек ", @@ -322,6 +323,7 @@ "settings_multisig_settings": "Настройки мультисиг-кошелька", "show": "Показать", "show_all": "Показать все", + "sk_input_label": "Закрытый ключ в hex формате без префикса 0x. Длина 128 символов", "start_trial_notification_description": "Подключение к Telegram необходимо только для проверки, что вы не бот.", "start_trial_notification_heading": "Подключите Telegram для пробного периода Pro", "swap_balance": "Баланс", @@ -409,6 +411,7 @@ "txActions_USDT_transfer": "Перевод USDT", "Undo": "Отменить", "Unexpected_QR_Code": "Неожиданный QR-код", + "unknown_operation": "Неизвестная операция", "Unlock": "Разблокировать", "update": "Обновить", "update_ledger_error": "Убедитесь, что программное обеспечение Ledger обновлено", @@ -420,6 +423,7 @@ "wallet_aside_collectibles": "Коллекции", "wallet_aside_domains": "Домены", "wallet_aside_history": "История", + "wallet_aside_home": "Главная", "wallet_aside_multisig_wallets": "Multisig кошельки", "wallet_aside_orders": "Заявки", "wallet_aside_purchases": "Покупки", diff --git a/packages/uikit/src/components/account/AccountBadge.tsx b/packages/uikit/src/components/account/AccountBadge.tsx index c3a42dfe7..f247e9a1f 100644 --- a/packages/uikit/src/components/account/AccountBadge.tsx +++ b/packages/uikit/src/components/account/AccountBadge.tsx @@ -200,7 +200,7 @@ export const AccountAndWalletBadgesGroup: FC<{ return ; } - if (account.type === 'mnemonic' || account.type === 'testnet') { + if (account.type === 'mnemonic' || account.type === 'testnet' || account.type === 'sk') { return null; } diff --git a/packages/uikit/src/components/create/ChoseWalletVersions.tsx b/packages/uikit/src/components/create/ChoseWalletVersions.tsx index 53bb41335..b887ea47d 100644 --- a/packages/uikit/src/components/create/ChoseWalletVersions.tsx +++ b/packages/uikit/src/components/create/ChoseWalletVersions.tsx @@ -66,18 +66,41 @@ const SubmitBlock = styled.div` width: 100%; `; -export const ChoseWalletVersions: FC<{ +export const ChoseWalletVersionsByMnemonic: FC<{ mnemonic: string[]; mnemonicType: MnemonicType; network: Network; onSubmit: (versions: WalletVersion[]) => void; isLoading?: boolean; }> = ({ mnemonic, onSubmit, network, isLoading, mnemonicType }) => { + const [publicKey, setPublicKey] = useState(undefined); + + useEffect(() => { + mnemonicToKeypair(mnemonic, mnemonicType).then(keypair => + setPublicKey(keypair.publicKey.toString('hex')) + ); + }, [mnemonic]); + + return ( + + ); +}; + +export const ChoseWalletVersions: FC<{ + publicKey: string | undefined; + network: Network; + onSubmit: (versions: WalletVersion[]) => void; + isLoading?: boolean; +}> = ({ publicKey, onSubmit, network, isLoading }) => { const { t } = useTranslation(); const sdk = useAppSdk(); const { defaultWalletVersion } = useAppContext(); - const [publicKey, setPublicKey] = useState(undefined); const { data: wallets } = useStandardTonWalletVersions(network, publicKey); const [checkedVersions, setCheckedVersions] = useState([]); const accountState = useAccountState(mayBeCreateAccountId(network, publicKey)); @@ -88,12 +111,6 @@ export const ChoseWalletVersions: FC<{ } }, []); - useEffect(() => { - mnemonicToKeypair(mnemonic, mnemonicType).then(keypair => - setPublicKey(keypair.publicKey.toString('hex')) - ); - }, [mnemonic]); - useLayoutEffect(() => { if (wallets) { if (accountState && isAccountTonWalletStandard(accountState)) { diff --git a/packages/uikit/src/components/create/SKInput.tsx b/packages/uikit/src/components/create/SKInput.tsx new file mode 100644 index 000000000..113e0177e --- /dev/null +++ b/packages/uikit/src/components/create/SKInput.tsx @@ -0,0 +1,83 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from '../../hooks/translation'; +import { CenterContainer } from '../Layout'; +import { H2Label2Responsive } from '../Text'; +import { ButtonResponsiveSize } from '../fields/Button'; +import { TextArea } from '../fields/Input'; +import { isValidSK } from '@tonkeeper/core/dist/service/mnemonicService'; + +const Block = styled.div` + display: flex; + text-align: center; + gap: 1rem; + flex-direction: column; +`; + +export const SKInput: FC<{ + afterInput: (address: string) => void; + isLoading?: boolean; + className?: string; + onIsDirtyChange?: (isDirty: boolean) => void; +}> = ({ afterInput, isLoading, className, onIsDirtyChange }) => { + const { t } = useTranslation(); + + const ref = useRef(null); + const [sk, setSk] = useState(''); + const [error, setError] = useState(false); + const [touched, setTouched] = useState(false); + + useEffect(() => { + onIsDirtyChange?.(!!sk); + }, [sk]); + + useEffect(() => { + if (!touched) { + return; + } + if (!sk || !isValidSK(sk)) { + setError(true); + } else { + setError(false); + } + }, [touched, sk]); + + useEffect(() => { + if (ref.current) { + ref.current.focus(); + } + }, [ref]); + + const onChange = (val: string) => { + setTouched(true); + setSk(val); + }; + + return ( + + +
+ {t('add_by_sk_title')} +
+