diff --git a/package.json b/package.json index 3219c40..16553aa 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "tiny-secp256k1": "./node_modules/@tapylet/core/dist/lib/secp256k1-compat.js" }, "dependencies": { - "@tapylet/core": "^0.0.7", + "@tapylet/core": "^0.0.8", "@plasmohq/storage": "^1.15.0", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c51a665..2789688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.15.0 version: 1.15.0(react@18.2.0) '@tapylet/core': - specifier: ^0.0.7 - version: 0.0.7(typescript@5.3.3) + specifier: ^0.0.8 + version: 0.0.8(typescript@5.3.3) i18next: specifier: ^25.8.13 version: 25.8.13(typescript@5.3.3) @@ -2246,8 +2246,8 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@tapylet/core@0.0.7': - resolution: {integrity: sha512-v8Y7YClcivaT7MmS7K8yn5Q6GZKbaO1XMcY4kuGqdoat/41LSPw5DHNu77iuywtlYFR/IbZ5y8DzRqj5kFen3w==} + '@tapylet/core@0.0.8': + resolution: {integrity: sha512-qhLcFpLmigG6tw7H7aAamzkdPzLZ2X1cVHr6evVCXILEBJnhnl/CsbSGgtNuzmu8kP4fYp1olyTmXM77LsVzqA==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -7572,7 +7572,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tapylet/core@0.0.7(typescript@5.3.3)': + '@tapylet/core@0.0.8(typescript@5.3.3)': dependencies: '@noble/hashes': 2.2.0 '@noble/secp256k1': 2.3.0 diff --git a/src/extension/components/wallet/AssetDetailModal.tsx b/src/extension/components/wallet/AssetDetailModal.tsx index f6b1f93..86cfa9c 100644 --- a/src/extension/components/wallet/AssetDetailModal.tsx +++ b/src/extension/components/wallet/AssetDetailModal.tsx @@ -144,6 +144,7 @@ export const AssetDetailModal: React.FC = ({ amount: parsedAmount, colorId, mnemonic: walletData.mnemonic, + networkId: network.id, }) setBurnTxid(result.txid) diff --git a/src/extension/components/wallet/IssueModal.tsx b/src/extension/components/wallet/IssueModal.tsx index 8051e9f..814467b 100644 --- a/src/extension/components/wallet/IssueModal.tsx +++ b/src/extension/components/wallet/IssueModal.tsx @@ -209,6 +209,7 @@ export const IssueModal: React.FC = ({ metadata, mnemonic: walletData.mnemonic, fromAddress: address, + networkId: network.id, }) // Save to local storage diff --git a/src/extension/components/wallet/SendModal.tsx b/src/extension/components/wallet/SendModal.tsx index cfada25..3b81bde 100644 --- a/src/extension/components/wallet/SendModal.tsx +++ b/src/extension/components/wallet/SendModal.tsx @@ -10,6 +10,15 @@ import { isValidAmount, parseAndValidateAmount, MAX_AMOUNT, MAX_COLORED_AMOUNT } interface SendModalProps { address: string + // TIP-0044 id of the network `address` (and any recipient address) is + // encoded for. + networkId: number + // Sign with the pre-network-split key instead of deriving one for + // networkId (see @tapylet/core/wallet/transaction#SendOptions). Only used + // by the legacy-address recovery screen. + fromLegacyMainnetWallet?: boolean + // Prefilled into the recipient field each time the modal opens; still editable. + defaultToAddress?: string tpcBalance: BalanceDetails assets: AssetBalance[] tokenMetadata: Map @@ -22,6 +31,9 @@ type SendStep = "input" | "confirm" | "sending" | "success" | "error" export const SendModal: React.FC = ({ address, + networkId, + fromLegacyMainnetWallet, + defaultToAddress = "", tpcBalance, assets, tokenMetadata, @@ -32,7 +44,7 @@ export const SendModal: React.FC = ({ const { t } = useTranslation() const [step, setStep] = useState("input") const [selectedColorId, setSelectedColorId] = useState(TPC_COLOR_ID) - const [toAddress, setToAddress] = useState("") + const [toAddress, setToAddress] = useState(defaultToAddress) const [amount, setAmount] = useState("") const [split, setSplit] = useState("1") const [error, setError] = useState(null) @@ -50,7 +62,7 @@ export const SendModal: React.FC = ({ const resetState = () => { setStep("input") setSelectedColorId(TPC_COLOR_ID) - setToAddress("") + setToAddress(defaultToAddress) setAmount("") setSplit("1") setError(null) @@ -186,6 +198,8 @@ export const SendModal: React.FC = ({ amount: sendAmount, split: sendSplit, mnemonic: walletData.mnemonic, + networkId, + fromLegacyMainnetWallet, }) } else { sendAmount = parseAndValidateAmount(amount, MAX_COLORED_AMOUNT, selectedDecimals)! @@ -196,6 +210,8 @@ export const SendModal: React.FC = ({ split: sendSplit, colorId: selectedColorId, mnemonic: walletData.mnemonic, + networkId, + fromLegacyMainnetWallet, }) } diff --git a/src/extension/i18n/locales/en.json b/src/extension/i18n/locales/en.json index 90e6ece..709f56f 100644 --- a/src/extension/i18n/locales/en.json +++ b/src/extension/i18n/locales/en.json @@ -11,7 +11,8 @@ "tryAgain": "Try Again", "copy": "Copy", "copied": "Copied!", - "decimalHint": "Up to {{decimals}} decimal places" + "decimalHint": "Up to {{decimals}} decimal places", + "settings": "Settings" }, "network": { "mainnet": "Mainnet", @@ -127,7 +128,8 @@ "password": "Password", "unlock": "Unlock", "errors": { - "incorrectPassword": "Incorrect password" + "incorrectPassword": "Incorrect password", + "walletUpdateFailed": "Failed to update the wallet data. Please unlock again." } }, "assetDetail": { @@ -178,7 +180,29 @@ "autoLockMinutes_other": "{{count}} minutes", "autoLockHours_one": "{{count}} hour", "autoLockHours_other": "{{count}} hours", - "autoLockNever": "Never" + "autoLockNever": "Never", + "legacyAddressTitle": "View the mainnet address from the older version", + "legacyAddressButton": "View" + }, + "legacyAddress": { + "title": "Previous mainnet address", + "description": "This is the mainnet address used by the older version. If anything remains here, you can send it to your new mainnet address.", + "address": "Address", + "switchToMainnet": "Switch to mainnet to check this address's balance.", + "switchButton": "Switch to mainnet", + "sendButton": "Send to the new address" + }, + "missingNetworkKey": { + "title": "No key generated for {{network}} yet", + "description": "Key generation failed for some reason. Please try again.", + "regenerate": "Generate key", + "error": "Failed to generate the key. Please try again." + }, + "legacyMigrationNotice": { + "title": "Your mainnet address has changed", + "description": "You can view and send from your previous mainnet address under Settings → \"View the mainnet address from the older version\".", + "openSettings": "Open settings", + "continueButton": "Continue" }, "issue": { "title": "Issue Asset", diff --git a/src/extension/i18n/locales/ja.json b/src/extension/i18n/locales/ja.json index b19265e..1b66a10 100644 --- a/src/extension/i18n/locales/ja.json +++ b/src/extension/i18n/locales/ja.json @@ -11,7 +11,8 @@ "tryAgain": "再試行", "copy": "コピー", "copied": "コピーしました", - "decimalHint": "小数点第{{decimals}}位まで入力可能" + "decimalHint": "小数点第{{decimals}}位まで入力可能", + "settings": "設定" }, "network": { "mainnet": "メインネット", @@ -127,7 +128,8 @@ "password": "パスワード", "unlock": "ロック解除", "errors": { - "incorrectPassword": "パスワードが正しくありません" + "incorrectPassword": "パスワードが正しくありません", + "walletUpdateFailed": "ウォレットデータの更新に失敗しました。もう一度ロック解除してください" } }, "assetDetail": { @@ -178,7 +180,29 @@ "autoLockMinutes_other": "{{count}} 分", "autoLockHours_one": "{{count}} 時間", "autoLockHours_other": "{{count}} 時間", - "autoLockNever": "ロックしない" + "autoLockNever": "ロックしない", + "legacyAddressTitle": "古いバージョンのメインネットアドレスを確認する", + "legacyAddressButton": "確認する" + }, + "legacyAddress": { + "title": "以前のメインネットアドレス", + "description": "古いバージョンで使用していたメインネットのアドレスです。ここに資産が残っている場合、新しいメインネットアドレスへ送金して移せます。", + "address": "アドレス", + "switchToMainnet": "このアドレスの残高を確認するには、メインネットに切り替えてください。", + "switchButton": "メインネットに切り替える", + "sendButton": "新しいアドレスへ送金する" + }, + "missingNetworkKey": { + "title": "{{network}}用の鍵が未生成です", + "description": "何らかの理由で鍵の生成に失敗しました。もう一度お試しください。", + "regenerate": "鍵を生成する", + "error": "鍵の生成に失敗しました。もう一度お試しください。" + }, + "legacyMigrationNotice": { + "title": "メインネットのアドレスが変わりました", + "description": "以前のメインネットアドレスは設定画面の「古いバージョンのメインネットアドレスを確認する」から確認・送金できます。", + "openSettings": "設定を開く", + "continueButton": "続ける" }, "issue": { "title": "アセット発行", diff --git a/src/extension/screens/LegacyAddressScreen.tsx b/src/extension/screens/LegacyAddressScreen.tsx new file mode 100644 index 0000000..23800d8 --- /dev/null +++ b/src/extension/screens/LegacyAddressScreen.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { Button, Card, CardContent, Loading } from "../components/ui" +import { AddressDisplay, SendModal } from "../components/wallet" +import { NetworkId } from "@tapylet/core/wallet" +import { + getAllBalances, + formatTpc, + formatTokenAmount, + formatColorId, + getTokenMetadataBatch, + Metadata, + type AllBalances, +} from "@tapylet/core/api" +import { pendingTxStore } from "~/extension/storage" +import { useNetwork } from "~/extension/hooks/useNetwork" +import type { AppScreen } from "~/extension/types/navigation" + +interface LegacyAddressScreenProps { + legacyMainnetAddress: string + // Where funds left at the legacy address are expected to go. Null when the + // mainnet key could not be derived, in which case nothing is prefilled. + newMainnetAddress: string | null + onNavigate: (screen: AppScreen) => void +} + +// The pre-network-split address was always mainnet-formatted (see +// @tapylet/core/wallet/hdwallet#getKeyPairFromLegacyMainnetWallet), so its +// balance can only be read while the panel is pointed at the mainnet +// explorer. Rather than juggling a second, temporary network configuration, +// this screen simply asks for mainnet to be selected first. +export const LegacyAddressScreen: React.FC = ({ + legacyMainnetAddress, + newMainnetAddress, + onNavigate, +}) => { + const { t } = useTranslation() + const { network, switchNetwork } = useNetwork() + const [balances, setBalances] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [tokenMetadata, setTokenMetadata] = useState>(new Map()) + const [showSendModal, setShowSendModal] = useState(false) + + const isOnMainnet = network.key === "mainnet" + + const refresh = useCallback(async () => { + if (!isOnMainnet) return + setIsLoading(true) + setLoadError(null) + try { + const allBal = await getAllBalances(legacyMainnetAddress) + setBalances(allBal) + if (allBal.assets.length > 0) { + const meta = await getTokenMetadataBatch(allBal.assets.map((a) => a.colorId)) + setTokenMetadata(meta) + } + } catch (err) { + console.error("Failed to load the legacy address balance:", err) + setLoadError(t("wallet.failedToLoad")) + } finally { + setIsLoading(false) + } + }, [isOnMainnet, legacyMainnetAddress, t]) + + useEffect(() => { + refresh() + }, [refresh]) + + return ( +
+
+
+ +

{t("legacyAddress.title")}

+
+
+ +
+

{t("legacyAddress.description")}

+ + + +

{t("legacyAddress.address")}

+ +
+
+ + {!isOnMainnet ? ( + + +

{t("legacyAddress.switchToMainnet")}

+ +
+
+ ) : ( + + +

{t("wallet.totalBalance")}

+ {isLoading ? ( + + ) : loadError ? ( +

{loadError}

+ ) : ( + <> +

+ {formatTpc(balances?.tpc.total ?? 0)} TPC +

+ {balances && balances.assets.length > 0 && ( +
+ {balances.assets.map((asset) => { + const meta = tokenMetadata.get(asset.colorId) + return ( +
+ + {meta ? `${meta.name} (${meta.symbol})` : formatColorId(asset.colorId)} + + + {formatTokenAmount(asset.total, meta?.decimals)} + +
+ ) + })} +
+ )} + + + )} +
+
+ )} +
+ + setShowSendModal(false)} + onSuccess={async (txid, amount, toAddress, colorId) => { + await pendingTxStore.add({ txid, amount, toAddress, timestamp: Date.now(), colorId }) + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + await refresh() + } + }} + /> +
+ ) +} diff --git a/src/extension/screens/LegacyMigrationNoticeScreen.tsx b/src/extension/screens/LegacyMigrationNoticeScreen.tsx new file mode 100644 index 0000000..f75827b --- /dev/null +++ b/src/extension/screens/LegacyMigrationNoticeScreen.tsx @@ -0,0 +1,38 @@ +import React from "react" +import { useTranslation } from "react-i18next" +import { Button } from "../components/ui" +import type { AppScreen } from "~/extension/types/navigation" + +interface LegacyMigrationNoticeScreenProps { + onNavigate: (screen: AppScreen) => void +} + +// Shown exactly once, right after a pre-network-split wallet's mnemonic +// derives separate mainnet/testnet keys (see UnlockScreen and +// ~/extension/storage/migrations.ts#ensureWalletNetworkKeys). The mainnet +// address on screen just changed out from under the user, so this says why +// and where the old one went before showing the new one. +export const LegacyMigrationNoticeScreen: React.FC = ({ + onNavigate, +}) => { + const { t } = useTranslation() + + return ( +
+

+ {t("legacyMigrationNotice.title")} +

+

+ {t("legacyMigrationNotice.description")} +

+
+ + +
+
+ ) +} diff --git a/src/extension/screens/MainWalletScreen.tsx b/src/extension/screens/MainWalletScreen.tsx index 82a985c..3c0d7f6 100644 --- a/src/extension/screens/MainWalletScreen.tsx +++ b/src/extension/screens/MainWalletScreen.tsx @@ -366,6 +366,7 @@ export const MainWalletScreen: React.FC = ({ {/* Send Modal */} void + onRegenerate: () => Promise +} + +// Shown instead of the main wallet screen when this network has no key yet — +// migration ran but failed to derive one (see +// ~/extension/storage/migrations.ts#ensureWalletNetworkKeys). Offers a retry +// rather than leaving the panel stuck, since the failure is typically +// transient (e.g. storage was briefly unavailable). +export const MissingNetworkKeyScreen: React.FC = ({ + network, + onNavigate, + onRegenerate, +}) => { + const { t } = useTranslation() + const [isRegenerating, setIsRegenerating] = useState(false) + const [error, setError] = useState(null) + + const handleRegenerate = async () => { + setIsRegenerating(true) + setError(null) + try { + await onRegenerate() + } catch (err) { + console.error("Failed to generate the network key:", err) + setError(t("missingNetworkKey.error")) + } finally { + setIsRegenerating(false) + } + } + + return ( +
+

+ {t("missingNetworkKey.title", { network: t(network.labelKey) })} +

+

{t("missingNetworkKey.description")}

+ {error &&

{error}

} +
+ + +
+
+ ) +} diff --git a/src/extension/screens/PasswordSetupScreen.tsx b/src/extension/screens/PasswordSetupScreen.tsx index 391a514..6a4575f 100644 --- a/src/extension/screens/PasswordSetupScreen.tsx +++ b/src/extension/screens/PasswordSetupScreen.tsx @@ -1,19 +1,25 @@ import React, { useState } from "react" import { useTranslation } from "react-i18next" import { Button, Input } from "../components/ui" -import { createHDWallet, generateAddress } from "@tapylet/core/wallet" +import { buildWalletData } from "~/extension/wallet/buildWalletData" import { walletStorage } from "~/extension/storage" import type { AppScreen } from "~/extension/types/navigation" -import type { WalletData } from "@tapylet/core/types/wallet" +import type { NetworkWalletKeys } from "@tapylet/core/types/wallet" interface PasswordSetupScreenProps { mnemonic: string + /** True when the mnemonic was typed in on the restore screen. */ + restored: boolean onNavigate: (screen: AppScreen) => void - onWalletCreated: (address: string) => void + onWalletCreated: ( + networks: Record, + legacyMainnetAddress: string | null, + ) => void } export const PasswordSetupScreen: React.FC = ({ mnemonic, + restored, onNavigate, onWalletCreated, }) => { @@ -40,26 +46,13 @@ export const PasswordSetupScreen: React.FC = ({ setIsCreating(true) try { - // Create HD wallet - const keys = await createHDWallet(mnemonic) - const address = generateAddress(keys.publicKey) + const walletData = await buildWalletData(mnemonic, { restored }) // Set password and save wallet await walletStorage.setPassword(password) - - const walletData: WalletData = { - mnemonic, // plaintext here; encrypted at rest by SecureStorage - address, - publicKey: Buffer.from(keys.publicKey).toString("hex"), - createdAt: Date.now(), - } - await walletStorage.saveWallet(walletData) - // Clear sensitive data from memory - keys.privateKey.fill(0) - - onWalletCreated(address) + onWalletCreated(walletData.networks, walletData.legacyMainnetAddress ?? null) onNavigate("main") } catch (err) { console.error("Failed to create wallet:", err) diff --git a/src/extension/screens/SettingsScreen.tsx b/src/extension/screens/SettingsScreen.tsx index 0eb7f26..a5aa253 100644 --- a/src/extension/screens/SettingsScreen.tsx +++ b/src/extension/screens/SettingsScreen.tsx @@ -8,6 +8,7 @@ import { NETWORK_KEYS, NETWORKS, type NetworkKey } from "~/extension/constants/n import type { AppScreen } from "~/extension/types/navigation" interface SettingsScreenProps { + legacyMainnetAddress: string | null onNavigate: (screen: AppScreen) => void } @@ -15,6 +16,7 @@ interface SettingsScreenProps { const APP_VERSION = chrome.runtime.getManifest().version export const SettingsScreen: React.FC = ({ + legacyMainnetAddress, onNavigate, }) => { const { t } = useTranslation() @@ -131,6 +133,24 @@ export const SettingsScreen: React.FC = ({ + {/* Legacy mainnet address */} + {legacyMainnetAddress && ( + + +
+
+

{t("settings.legacyAddressTitle")}

+
+ +
+
+
+ )} + {/* Security */} diff --git a/src/extension/screens/UnlockScreen.tsx b/src/extension/screens/UnlockScreen.tsx index 071e4a0..e23546a 100644 --- a/src/extension/screens/UnlockScreen.tsx +++ b/src/extension/screens/UnlockScreen.tsx @@ -2,11 +2,16 @@ import React, { useState } from "react" import { useTranslation } from "react-i18next" import { Button, Input } from "../components/ui" import { walletStorage } from "~/extension/storage" +import { ensureWalletNetworkKeys } from "~/extension/storage/migrations" import type { AppScreen } from "~/extension/types/navigation" +import type { NetworkWalletKeys } from "@tapylet/core/types/wallet" interface UnlockScreenProps { onNavigate: (screen: AppScreen) => void - onUnlock: (address: string) => void + onUnlock: ( + networks: Record, + legacyMainnetAddress: string | null, + ) => void } export const UnlockScreen: React.FC = ({ @@ -45,8 +50,20 @@ export const UnlockScreen: React.FC = ({ return } - onUnlock(wallet.address) - onNavigate("main") + // The password is already verified here, so a failure from this point + // on is not the user's input and must not be reported as one. + try { + const { wallet: migrated, changed, migratedFromLegacyFormat } = await ensureWalletNetworkKeys(wallet) + if (changed) { + await walletStorage.saveWallet(migrated) + } + + onUnlock(migrated.networks, migrated.legacyMainnetAddress ?? null) + onNavigate(migratedFromLegacyFormat ? "legacy-migration-notice" : "main") + } catch (err) { + console.error("Failed to update the wallet data:", err) + setError(t("unlock.errors.walletUpdateFailed")) + } } catch (err) { console.error("Failed to unlock:", err) setError(t("unlock.errors.incorrectPassword")) diff --git a/src/extension/screens/index.ts b/src/extension/screens/index.ts index fc5ea41..5149dcb 100644 --- a/src/extension/screens/index.ts +++ b/src/extension/screens/index.ts @@ -8,3 +8,6 @@ export { UnlockScreen } from "./UnlockScreen" export { ConsentUpdateScreen } from "./ConsentUpdateScreen" export { MainWalletScreen } from "./MainWalletScreen" export { SettingsScreen } from "./SettingsScreen" +export { LegacyAddressScreen } from "./LegacyAddressScreen" +export { LegacyMigrationNoticeScreen } from "./LegacyMigrationNoticeScreen" +export { MissingNetworkKeyScreen } from "./MissingNetworkKeyScreen" diff --git a/src/extension/storage/migrations.ts b/src/extension/storage/migrations.ts index 03e4bc3..8b2a736 100644 --- a/src/extension/storage/migrations.ts +++ b/src/extension/storage/migrations.ts @@ -13,12 +13,15 @@ // registration — none of which can be recovered once the key is orphaned. import type { KeyValueStore } from "@tapylet/core/storage/types" +import type { WalletData } from "@tapylet/core/types/wallet" import { networkKeyPrefix } from "./adapters/prefixed" import { SELECTED_NETWORK_KEY } from "./networkStore" +import { deriveNetworkWallet } from "~/extension/wallet/deriveNetworkWallet" import { DEFAULT_NETWORK, + NETWORK_KEYS, NETWORKS, type NetworkKey, } from "~/extension/constants/network" @@ -83,3 +86,64 @@ export const settleInitialNetworkChoice = async ( const key = (await walletExists()) ? LEGACY_NETWORK : DEFAULT_NETWORK await storage.set(SELECTED_NETWORK_KEY, key) } + +/** + * Fills in any network this wallet doesn't have a key for yet, and — for a + * wallet that predates per-network keys — carries its one address forward as + * `legacyMainnetAddress` instead of discarding it. Real funds may still sit + * at that address (it was always mainnet-formatted), so it is never + * recomputed, only copied once. + * + * Unlike the migrations above, this can only run after unlock: deriving from + * the mnemonic needs it decrypted. Each network is derived independently, so + * one failing (a transient error, unexpected data) still leaves the other + * usable; the host is expected to offer a retry for whichever is missing + * (see ~/extension/screens/MissingNetworkKeyScreen). + * + * Returns `changed: false` without touching `wallet` when nothing was + * missing, so the caller can skip an unnecessary write. `migratedFromLegacyFormat` + * is true only on the one call that consumes a pre-split wallet's `address` + * into `legacyMainnetAddress` — the host uses it to show a one-time notice + * that the mainnet address on screen has changed. + */ +export const ensureWalletNetworkKeys = async ( + wallet: WalletData, +): Promise<{ wallet: WalletData; changed: boolean; migratedFromLegacyFormat: boolean }> => { + let changed = false + let migratedFromLegacyFormat = false + const networks = { ...wallet.networks } + let legacyMainnetAddress = wallet.legacyMainnetAddress + + // @tapylet/core's getWallet already copies a pre-split `address` into + // `legacyMainnetAddress`, so the record still holding `address` (dropped on + // the save below) is what marks it as pre-split, not the copy being missing. + if (wallet.address) { + legacyMainnetAddress ??= wallet.address + changed = true + migratedFromLegacyFormat = true + } + + for (const key of NETWORK_KEYS) { + const networkId = NETWORKS[key].id + if (networks[networkId]) continue + try { + networks[networkId] = await deriveNetworkWallet(wallet.mnemonic, networkId) + changed = true + } catch (err) { + console.error(`Failed to derive the ${key} key:`, err) + } + } + + if (!changed) return { wallet, changed: false, migratedFromLegacyFormat: false } + + const migrated: WalletData = { + ...wallet, + networks, + legacyMainnetAddress, + } + delete migrated.address + delete migrated.publicKey + delete migrated.encryptedMnemonic + + return { wallet: migrated, changed: true, migratedFromLegacyFormat } +} diff --git a/src/extension/types/navigation.ts b/src/extension/types/navigation.ts index b0ef4f8..4c0c932 100644 --- a/src/extension/types/navigation.ts +++ b/src/extension/types/navigation.ts @@ -11,8 +11,12 @@ export type AppScreen = | "unlock" // Only reached when a legal document has been revised. | "consent" + // Only reached once, right after a pre-network-split wallet gets separate + // mainnet/testnet keys. + | "legacy-migration-notice" | "main" | "settings" + | "legacy-address" export interface NavigationState { screen: AppScreen diff --git a/src/extension/wallet/buildWalletData.ts b/src/extension/wallet/buildWalletData.ts new file mode 100644 index 0000000..2e2ddce --- /dev/null +++ b/src/extension/wallet/buildWalletData.ts @@ -0,0 +1,39 @@ +import type { WalletData, NetworkWalletKeys } from "@tapylet/core/types/wallet" + +import { NETWORK_KEYS, NETWORKS } from "~/extension/constants/network" + +import { deriveNetworkWallet } from "./deriveNetworkWallet" + +/** + * Builds the WalletData stored when a wallet is created or restored. + * + * A restored mnemonic may belong to a wallet made before mainnet and testnet + * had separate keys, and nothing in the mnemonic tells the two apart. Its old + * mainnet address may still hold funds, so a restore always records one: the + * pre-split derivation is the same as today's testnet derivation (pinned by + * @tapylet/core's createLegacyMainnetWallet tests), so the testnet address is + * that old address. For a wallet made after the split it is an address with no + * mainnet history, which only costs an empty screen. + * + * Deriving from a mnemonic is a pure, offline computation, so unlike the + * unlock-time backfill a failure here fails the whole build instead of leaving + * a network out. + */ +export const buildWalletData = async ( + mnemonic: string, + opts: { restored: boolean }, +): Promise => { + const networks: Record = {} + for (const key of NETWORK_KEYS) { + networks[NETWORKS[key].id] = await deriveNetworkWallet(mnemonic, NETWORKS[key].id) + } + + return { + mnemonic, // plaintext here; encrypted at rest by SecureStorage + networks, + createdAt: Date.now(), + ...(opts.restored && { + legacyMainnetAddress: networks[NETWORKS.testnet.id].address, + }), + } +} diff --git a/src/extension/wallet/deriveNetworkWallet.ts b/src/extension/wallet/deriveNetworkWallet.ts new file mode 100644 index 0000000..b7edb57 --- /dev/null +++ b/src/extension/wallet/deriveNetworkWallet.ts @@ -0,0 +1,16 @@ +import { createHDWallet, generateAddress } from "@tapylet/core/wallet" +import type { NetworkWalletKeys } from "@tapylet/core/types/wallet" + +// Derives the address/publicKey pair a WalletData.networks entry holds for +// one network. Zeroes the private key once the public key has been read from +// it, same as the wallet-creation flow that used to do this inline. +export const deriveNetworkWallet = async ( + mnemonic: string, + networkId: number, +): Promise => { + const keys = await createHDWallet(mnemonic, networkId) + const address = generateAddress(keys.publicKey) + const publicKey = Buffer.from(keys.publicKey).toString("hex") + keys.privateKey.fill(0) + return { address, publicKey } +} diff --git a/src/sidepanel.tsx b/src/sidepanel.tsx index bde36d1..db914c0 100644 --- a/src/sidepanel.tsx +++ b/src/sidepanel.tsx @@ -5,7 +5,7 @@ // unconfigured core. Today core is only touched at render time, which is well // after this runs either way — the position is what keeps that from being // load-bearing. -import "~/extension/constants/network" +import { NETWORKS } from "~/extension/constants/network" import { useCallback, useEffect, useState } from "react" import { Loading } from "~/extension/components/ui" @@ -16,15 +16,31 @@ import { useAutoLock } from "~/extension/hooks/useAutoLock" import { NetworkProvider, useNetwork } from "~/extension/hooks/useNetwork" import { applyLegalManifest, LEGAL_DOC_IDS, majorsOf, outdatedDocs, type LegalDocId } from "~/extension/legal" import { fetchLegalManifest } from "~/extension/legalManifest" -import { WelcomeScreen, CreateWalletScreen, MnemonicDisplayScreen, MnemonicConfirmScreen, PasswordSetupScreen, RestoreWalletScreen, UnlockScreen, ConsentUpdateScreen, MainWalletScreen, SettingsScreen } from "~/extension/screens" +import { deriveNetworkWallet } from "~/extension/wallet/deriveNetworkWallet" +import { + WelcomeScreen, + CreateWalletScreen, + MnemonicDisplayScreen, + MnemonicConfirmScreen, + PasswordSetupScreen, + RestoreWalletScreen, + UnlockScreen, + ConsentUpdateScreen, + MainWalletScreen, + SettingsScreen, + LegacyAddressScreen, + LegacyMigrationNoticeScreen, + MissingNetworkKeyScreen, +} from "~/extension/screens" import type { AppScreen } from "~/extension/types/navigation" +import type { NetworkWalletKeys } from "@tapylet/core/types/wallet" import "~/extension/i18n" import "./styles/globals.css" // The screens that carry consent checkboxes of their own. const CONSENT_SCREENS: AppScreen[] = ["welcome", "consent"] -const UNLOCKED_SCREENS: AppScreen[] = ["main", "settings"] +const UNLOCKED_SCREENS: AppScreen[] = ["main", "settings", "legacy-address", "legacy-migration-notice"] function SidePanelContent() { // The persisted network is read asynchronously; until it arrives the panel @@ -32,7 +48,10 @@ function SidePanelContent() { const { network, isReady: isNetworkReady } = useNetwork() const [screen, setScreen] = useState("loading") const [tempMnemonic, setTempMnemonic] = useState(null) - const [address, setAddress] = useState(null) + const [tempMnemonicRestored, setTempMnemonicRestored] = useState(false) + const [walletNetworks, setWalletNetworks] = useState | null>(null) + const [legacyMainnetAddress, setLegacyMainnetAddress] = useState(null) + const address = walletNetworks?.[network.id]?.address ?? null const [autoLockMinutes, setAutoLockMinutes] = useState(DEFAULT_AUTO_LOCK_MINUTES) // The documents that have to be agreed to (acknowledged) again. Only consulted // for a wallet that already exists: the welcome screen carries its own @@ -109,22 +128,55 @@ function SidePanelContent() { useAutoLock(isUnlockedScreen ? autoLockMinutes * 60 * 1000 : 0, handleAutoLock) const handleNavigate = (newScreen: AppScreen) => setScreen(newScreen) - const handleMnemonicGenerated = (mnemonic: string) => setTempMnemonic(mnemonic) - const handleMnemonicEntered = (mnemonic: string) => setTempMnemonic(mnemonic) + const handleMnemonicGenerated = (mnemonic: string) => { + setTempMnemonic(mnemonic) + setTempMnemonicRestored(false) + } + const handleMnemonicEntered = (mnemonic: string) => { + setTempMnemonic(mnemonic) + setTempMnemonicRestored(true) + } const recordConsent = (docs: readonly LegalDocId[]) => consentStore .agree([...docs]) .catch((err) => console.error("Failed to store the consent:", err)) // The welcome screen took the consent for both documents, so the wallet and // the record of what was agreed to come into being together. - const handleWalletCreated = (walletAddress: string) => { setAddress(walletAddress); setTempMnemonic(null); recordConsent(LEGAL_DOC_IDS) } + const handleWalletCreated = ( + networks: Record, + legacyAddress: string | null, + ) => { + setWalletNetworks(networks) + setLegacyMainnetAddress(legacyAddress) + setTempMnemonic(null) + setTempMnemonicRestored(false) + recordConsent(LEGAL_DOC_IDS) + } const handleConsentAgree = async () => { // Only the documents the screen put to the user. await recordConsent(outdatedConsents) setOutdatedConsents([]) setScreen("unlock") } - const handleUnlock = (walletAddress: string) => setAddress(walletAddress) + const handleUnlock = ( + networks: Record, + legacyAddress: string | null, + ) => { + setWalletNetworks(networks) + setLegacyMainnetAddress(legacyAddress) + } + // Retries deriving this network's key after ensureWalletNetworkKeys left it + // missing (see ~/extension/screens/MissingNetworkKeyScreen). The session is + // already unlocked, so the mnemonic is available without asking for the + // password again. + const handleRegenerateNetworkKey = async () => { + const wallet = await walletStorage.getWallet() + if (!wallet) throw new Error("Wallet not found") + const keys = await deriveNetworkWallet(wallet.mnemonic, network.id) + const updatedNetworks = { ...wallet.networks, [network.id]: keys } + await walletStorage.saveWallet({ ...wallet, networks: updatedNetworks }) + setWalletNetworks(updatedNetworks) + } const renderScreen = () => { if (!isNetworkReady) { @@ -136,15 +188,21 @@ function SidePanelContent() { case "create": return case "mnemonic-display": return tempMnemonic ? : null case "mnemonic-confirm": return tempMnemonic ? : null - case "password-setup": return tempMnemonic ? : null + case "password-setup": return tempMnemonic ? : null case "restore": return case "unlock": return case "consent": return + case "legacy-migration-notice": return // Keyed on the network so a switch remounts the screen: every balance, // asset and pending transaction it holds belongs to the previous chain, // and remounting discards them all rather than clearing each. - case "main": return address ? : null - case "settings": return + case "main": + if (!walletNetworks) return null + return address + ? + : + case "settings": return + case "legacy-address": return legacyMainnetAddress ? : null default: return } } diff --git a/test/buildWalletData.test.ts b/test/buildWalletData.test.ts new file mode 100644 index 0000000..ec16c4e --- /dev/null +++ b/test/buildWalletData.test.ts @@ -0,0 +1,35 @@ +import { buildWalletData } from "~/extension/wallet/buildWalletData" + +import { NETWORKS } from "~/extension/constants/network" + +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +describe("buildWalletData", () => { + it("records the testnet address as the legacy mainnet address for a restored wallet", async () => { + const wallet = await buildWalletData(TEST_MNEMONIC, { restored: true }) + + // The pre-split derivation equals today's testnet derivation, so a + // restored pre-split wallet's old mainnet funds sit at this address. + expect(wallet.legacyMainnetAddress).toBe(wallet.networks[NETWORKS.testnet.id].address) + }) + + it("records no legacy mainnet address for a newly created wallet", async () => { + const wallet = await buildWalletData(TEST_MNEMONIC, { restored: false }) + + expect(wallet).not.toHaveProperty("legacyMainnetAddress") + }) + + it("derives distinct prod-format addresses for both networks", async () => { + const wallet = await buildWalletData(TEST_MNEMONIC, { restored: false }) + + const mainnet = wallet.networks[NETWORKS.mainnet.id] + const testnet = wallet.networks[NETWORKS.testnet.id] + expect(mainnet.address[0]).toBe("1") + expect(testnet.address[0]).toBe("1") + expect(mainnet.address).not.toBe(testnet.address) + expect(wallet.mnemonic).toBe(TEST_MNEMONIC) + expect(wallet).not.toHaveProperty("address") + expect(wallet).not.toHaveProperty("publicKey") + }) +}) diff --git a/test/migrations.test.ts b/test/migrations.test.ts index 8e72486..cc779ed 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -14,10 +14,13 @@ import { } from "@tapylet/core/storage/pendingTxStore" import type { KeyValueStore } from "@tapylet/core/storage/types" +import type { WalletData } from "@tapylet/core/types/wallet" + import { networkKeyPrefix } from "~/extension/storage/adapters/prefixed" import { migrateLegacyNetworkKeys, settleInitialNetworkChoice, + ensureWalletNetworkKeys, } from "~/extension/storage/migrations" import { SELECTED_NETWORK_KEY } from "~/extension/storage/networkStore" @@ -232,3 +235,120 @@ describe("settleInitialNetworkChoice", () => { expect(storage.values).toEqual(afterFirst) }) }) + +// Gives a wallet created before mainnet/testnet had separate keys both, and +// carries its one address forward so any real funds sent to it stay +// reachable. Real derivation (a BIP39 test vector), not mocked, so the +// address shapes are asserted along with the migration bookkeeping. +describe("ensureWalletNetworkKeys", () => { + const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + const baseWallet = (overrides: Partial = {}): WalletData => ({ + mnemonic: TEST_MNEMONIC, + createdAt: 1700000000000, + networks: {}, + ...overrides, + }) + + it("derives both networks and carries the old address forward for a pre-split wallet", async () => { + const wallet = baseWallet({ + networks: undefined as unknown as Record, + address: "1LegacyAddressXXXXXXXXXXXXXXXXXXXX", + publicKey: "02legacy", + encryptedMnemonic: "stale-copy-of-the-mnemonic", + }) + + const { wallet: migrated, changed, migratedFromLegacyFormat } = + await ensureWalletNetworkKeys(wallet) + + expect(changed).toBe(true) + expect(migratedFromLegacyFormat).toBe(true) + expect(migrated.legacyMainnetAddress).toBe("1LegacyAddressXXXXXXXXXXXXXXXXXXXX") + expect(migrated.address).toBeUndefined() + expect(migrated.publicKey).toBeUndefined() + expect(migrated.encryptedMnemonic).toBeUndefined() + + const mainnet = migrated.networks[NETWORKS.mainnet.id] + const testnet = migrated.networks[NETWORKS.testnet.id] + // Both networks encode addresses in the prod P2PKH format; only the + // derivation path differs, so the addresses must still differ. + expect(mainnet.address[0]).toBe("1") + expect(testnet.address[0]).toBe("1") + expect(mainnet.address).not.toBe(testnet.address) + }) + + it("still reports a pre-split wallet as migrated after @tapylet/core's getWallet copied its address", async () => { + // The shape walletStorage.getWallet() returns for a pre-split record. + const wallet = baseWallet({ + networks: {}, + address: "1LegacyAddressXXXXXXXXXXXXXXXXXXXX", + publicKey: "02legacy", + legacyMainnetAddress: "1LegacyAddressXXXXXXXXXXXXXXXXXXXX", + }) + + const { wallet: migrated, changed, migratedFromLegacyFormat } = + await ensureWalletNetworkKeys(wallet) + + expect(changed).toBe(true) + expect(migratedFromLegacyFormat).toBe(true) + expect(migrated.legacyMainnetAddress).toBe("1LegacyAddressXXXXXXXXXXXXXXXXXXXX") + expect(migrated.address).toBeUndefined() + + // Once saved without `address`, the next unlock does not report it again. + const second = await ensureWalletNetworkKeys(migrated) + expect(second.migratedFromLegacyFormat).toBe(false) + }) + + it("does nothing for a wallet that already has both keys", async () => { + const wallet = baseWallet({ + networks: { + [NETWORKS.mainnet.id]: { address: "1Already", publicKey: "02aa" }, + [NETWORKS.testnet.id]: { address: "mAlready", publicKey: "02bb" }, + }, + }) + + const result = await ensureWalletNetworkKeys(wallet) + + expect(result.changed).toBe(false) + expect(result.migratedFromLegacyFormat).toBe(false) + expect(result.wallet).toBe(wallet) // untouched, not even a shallow copy + }) + + it("fills in only the network that is missing", async () => { + const wallet = baseWallet({ + networks: { + [NETWORKS.mainnet.id]: { address: "1Already", publicKey: "02aa" }, + }, + }) + + const { wallet: migrated, changed, migratedFromLegacyFormat } = + await ensureWalletNetworkKeys(wallet) + + expect(changed).toBe(true) + expect(migratedFromLegacyFormat).toBe(false) + expect(migrated.networks[NETWORKS.mainnet.id]).toEqual({ address: "1Already", publicKey: "02aa" }) + expect(migrated.networks[NETWORKS.testnet.id].address[0]).toBe("1") + }) + + it("leaves a network missing when deriving it fails, without blocking the other", async () => { + const deriveModule = await import("~/extension/wallet/deriveNetworkWallet") + const spy = jest + .spyOn(deriveModule, "deriveNetworkWallet") + .mockImplementation(async (mnemonic, networkId) => { + if (networkId === NETWORKS.testnet.id) throw new Error("boom") + return { address: "1Derived", publicKey: "02cc" } + }) + + try { + const wallet = baseWallet() + const { wallet: migrated, changed } = await ensureWalletNetworkKeys(wallet) + + expect(changed).toBe(true) + expect(migrated.networks[NETWORKS.mainnet.id]).toEqual({ address: "1Derived", publicKey: "02cc" }) + expect(migrated.networks[NETWORKS.testnet.id]).toBeUndefined() + } finally { + spy.mockRestore() + } + }) +})