diff --git a/App.tsx b/App.tsx index 6a5c64a8c..8cce97358 100644 --- a/App.tsx +++ b/App.tsx @@ -223,9 +223,11 @@ import WalletSettings from './views/Onboarding/WalletSettings'; import EditFee from './views/EditFee'; -// Embedded LND +// Embedded LND/LDK import Seed from './views/Settings/Seed'; import SeedRecovery from './views/Settings/SeedRecovery'; +import LdkWalletRecoverySettings from './views/Settings/LdkRecovery/LdkWalletRecoverySettings'; +import LdkRecoveryVssServer from './views/Settings/LdkRecovery/LdkRecoveryVssServer'; import SeedQRExport from './views/Settings/SeedQRExport'; import Sync from './views/Sync'; import SyncRecovery from './views/SyncRecovery'; @@ -1085,6 +1087,18 @@ export default class App extends React.PureComponent { SeedRecovery } /> + + void; + locked?: boolean; +} + +export const resolveVssServer = ( + selectedValue: string, + customServer: string +): string => + selectedValue === VSS_CUSTOM_VALUE ? customServer.trim() : selectedValue; + +export const isVssServerValid = ( + selectedValue: string, + customServer: string +): boolean => { + if (selectedValue !== VSS_CUSTOM_VALUE) return true; + const trimmed = customServer.trim(); + return trimmed !== '' && UrlUtils.isValidUrl(trimmed); +}; + +export default function VssServerPicker({ + selectedValue, + customServer, + onChange, + locked +}: VssServerPickerProps) { + const isCustom = selectedValue === VSS_CUSTOM_VALUE; + const showInvalidUrlError = + isCustom && + customServer.trim() !== '' && + !UrlUtils.isValidUrl(customServer); + + return ( + + + onChange( + value, + value === VSS_CUSTOM_VALUE ? customServer : '' + ) + } + values={LDK_VSS_SERVER_KEYS} + disabled={locked} + /> + + {isCustom && ( + <> + + onChange(selectedValue, text) + } + autoCapitalize="none" + autoCorrect={false} + locked={locked} + /> + {showInvalidUrlError && ( + + {localeString( + 'views.Settings.EmbeddedNode.invalidServerUrl' + )} + + )} + + )} + + ); +} diff --git a/locales/en.json b/locales/en.json index b764cae68..dfd7df33a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -359,6 +359,9 @@ "views.Settings.AddEditNode.certificateVerification": "Certificate Verification", "views.Settings.AddEditNode.createLndhub": "Create LNDHub account", "views.Settings.WalletConfiguration.database": "Database", + "views.Settings.WalletConfiguration.seedPhraseLength": "Seed phrase length", + "views.Settings.WalletConfiguration.seedPhraseLength.12": "12 words", + "views.Settings.WalletConfiguration.seedPhraseLength.24": "24 words", "views.Settings.WalletConfiguration.saveWallet": "Save Wallet Config", "views.Settings.WalletConfiguration.setWalletActive": "Set Wallet Config as Active", "views.Settings.WalletConfiguration.walletActive": "Wallet Active", @@ -1387,6 +1390,10 @@ "views.Settings.SeedRecovery.clipboardSeedWords": "Seed words detected in clipboard", "views.Settings.SeedRecovery.clipboardSeedWordsPrompt": "Would you like to use them for wallet recovery?", "views.Settings.SeedRecovery.duplicateWallet": "A wallet with the same seed phrase already exists", + "views.Settings.SeedRecovery.invalidChecksum": "Invalid seed phrase — checksum failed. Please check the word order and spelling.", + "views.Settings.LdkWalletRecoverySettings.title": "Recovery options", + "views.Settings.LdkWalletRecoverySettings.seedPhraseLength": "How many words is your seed phrase?", + "views.Settings.LdkWalletRecoverySettings.seedPhraseLength.subtitle": "Most wallets use 12 words. Choose 24 only if your backup has 24 words.", "views.Settings.SeedQRExport.title": "Export HD node root keys", "views.Settings.SeedQRExport.pleaseWait": "Generating yprv and zprv. This will take just a few moments.", "views.Settings.SeedQRExport.warning": "WARNING: DO NOT bump a channel opening transaction. This can cause you to lose funds!", diff --git a/stores/CashuStore.ts b/stores/CashuStore.ts index a1d3750bf..0899cc52b 100644 --- a/stores/CashuStore.ts +++ b/stores/CashuStore.ts @@ -491,9 +491,22 @@ export default class CashuStore { let cashuSeedPhrase: string; if (ldkMnemonic) { - // LDK Node uses a 12-word mnemonic - use it directly as the - // cashu seed since it already has exactly 128 bits of entropy - cashuSeedPhrase = ldkMnemonic; + const ldkWordCount = ldkMnemonic.trim().split(/\s+/).length; + if (ldkWordCount === 12) { + // A 12-word LDK mnemonic already has exactly 128 bits of + // entropy - use it directly as the cashu seed + cashuSeedPhrase = ldkMnemonic; + } else { + // Larger LDK mnemonics (e.g. 24 words) derive a 12-word cashu + // seed from bytes [48:64] of the BIP-39 seed (v2-bip39 style) + const seedFromMnemonic = + bip39scure.mnemonicToSeedSync(ldkMnemonic); + const entropy = seedFromMnemonic.slice(48, 64); + cashuSeedPhrase = bip39scure.entropyToMnemonic( + entropy, + BIP39_WORD_LIST + ); + } } else if (lndSeedPhrase && lndSeedPhrase.length > 0) { // LND uses a 24-word mnemonic - derive a 12-word cashu seed // from bytes [48:64] of the BIP-39 seed (v2-bip39 style) diff --git a/stores/SettingsStore.ts b/stores/SettingsStore.ts index 9ce8e201d..0e4e302db 100644 --- a/stores/SettingsStore.ts +++ b/stores/SettingsStore.ts @@ -427,6 +427,11 @@ export const EMBEDDED_NODE_NETWORK_KEYS = [ } ]; +export const LDK_VSS_SERVER_KEYS = [ + { key: 'ZEUS', value: DEFAULT_VSS_SERVER }, + { key: 'Custom', translateKey: 'general.custom', value: 'custom' } +]; + export const LNC_MAILBOX_KEYS = [ { key: 'mailbox.terminal.lightning.today:443', diff --git a/stores/SwapStore.ts b/stores/SwapStore.ts index 941fc225b..2679f7f0b 100644 --- a/stores/SwapStore.ts +++ b/stores/SwapStore.ts @@ -777,13 +777,17 @@ export default class SwapStore { @action public generateRescueKey = async () => { console.log('GENERATING RESCUE FILE...'); - // LDK Node already has a 12-word BIP-39 mnemonic - reuse it - // instead of generating a separate rescue key + // A 12-word LDK Node wallet already has a 12-word BIP-39 mnemonic - + // reuse it instead of generating a separate rescue key. 24-word LDK + // wallets (and every other backend) get an independent 12-word key. const { implementation, ldkMnemonic } = this.settingsStore; - const mnemonic = - implementation === 'ldk-node' && ldkMnemonic - ? ldkMnemonic - : generateMnemonic(BIP39_WORD_LIST); + const reuseLdkSeed = + implementation === 'ldk-node' && + !!ldkMnemonic && + ldkMnemonic.trim().split(/\s+/).length === 12; + const mnemonic = reuseLdkSeed + ? ldkMnemonic + : generateMnemonic(BIP39_WORD_LIST); await Storage.setItem(SWAPS_RESCUE_KEY, mnemonic); return mnemonic; diff --git a/utils/LdkNodeUtils.ts b/utils/LdkNodeUtils.ts index 8a5a18471..c8926b278 100644 --- a/utils/LdkNodeUtils.ts +++ b/utils/LdkNodeUtils.ts @@ -243,6 +243,7 @@ async function initNode({ export async function createLdkNodeWallet({ nodeDir, seedMnemonic, + wordCount = 12, passphrase, network, esploraServerUrl, @@ -255,6 +256,7 @@ export async function createLdkNodeWallet({ }: { nodeDir: string; seedMnemonic?: string; + wordCount?: number; passphrase?: string; network: SupportedNetwork; esploraServerUrl?: string; @@ -279,7 +281,7 @@ export async function createLdkNodeWallet({ // Generate mnemonic if not provided (new wallet) let mnemonic = seedMnemonic; if (!mnemonic) { - mnemonic = await generateMnemonic(12); + mnemonic = await generateMnemonic(wordCount); } const { vssError } = await initNode({ diff --git a/views/Settings/EmbeddedNode/VssServer.tsx b/views/Settings/EmbeddedNode/VssServer.tsx index b6fc69c8f..9078f9a02 100644 --- a/views/Settings/EmbeddedNode/VssServer.tsx +++ b/views/Settings/EmbeddedNode/VssServer.tsx @@ -6,14 +6,19 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import Button from '../../../components/Button'; import Header from '../../../components/Header'; import Screen from '../../../components/Screen'; -import TextInput from '../../../components/TextInput'; +import VssServerPicker, { + VSS_CUSTOM_VALUE, + resolveVssServer, + isVssServerValid +} from '../../../components/VssServerPicker'; -import SettingsStore from '../../../stores/SettingsStore'; +import SettingsStore, { + LDK_VSS_SERVER_KEYS +} from '../../../stores/SettingsStore'; import { localeString } from '../../../utils/LocaleUtils'; import { restartNeeded } from '../../../utils/RestartUtils'; import { themeColor } from '../../../utils/ThemeUtils'; -import UrlUtils from '../../../utils/UrlUtils'; import { DEFAULT_VSS_SERVER } from '../../../utils/LdkNodeUtils'; interface VssServerProps { @@ -22,7 +27,8 @@ interface VssServerProps { } interface VssServerState { - vssServer: string; + selectedValue: string; + customServer: string; savedVssServer: string; } @@ -35,9 +41,15 @@ export default class VssServer extends React.Component< state = (() => { const { settings } = this.props.SettingsStore; const selectedNode = settings.selectedNode || 0; - const saved = settings.nodes?.[selectedNode]?.ldkVssServer || ''; + const saved = + settings.nodes?.[selectedNode]?.ldkVssServer || DEFAULT_VSS_SERVER; + + const presetValues = LDK_VSS_SERVER_KEYS.map((s) => s.value); + const isPreset = presetValues.includes(saved); + return { - vssServer: saved, + selectedValue: isPreset ? saved : VSS_CUSTOM_VALUE, + customServer: isPreset ? '' : saved, savedVssServer: saved }; })(); @@ -60,16 +72,15 @@ export default class VssServer extends React.Component< render() { const { navigation } = this.props; - const { vssServer, savedVssServer } = this.state; - - const showReset = vssServer !== DEFAULT_VSS_SERVER; - - const vssServerTrimmed = vssServer.trim(); - const showInvalidUrlError = - vssServerTrimmed !== '' && !UrlUtils.isValidUrl(vssServer); + const { selectedValue, customServer, savedVssServer } = this.state; + const effectiveServer = resolveVssServer(selectedValue, customServer); + const isValid = isVssServerValid(selectedValue, customServer); const hasUnsavedChanges = - vssServer !== savedVssServer && !showInvalidUrlError; + isValid && + effectiveServer !== '' && + effectiveServer !== savedVssServer; + const showReset = effectiveServer !== DEFAULT_VSS_SERVER; return ( @@ -102,43 +113,17 @@ export default class VssServer extends React.Component< - - {localeString( - 'views.Settings.EmbeddedNode.VssServer.serverUrl' - )} - - { + this.setState({ - vssServer: text - }); - }} - autoCapitalize="none" - autoCorrect={false} + selectedValue: value, + customServer: custom + }) + } /> - {showInvalidUrlError && ( - - {localeString( - 'views.Settings.EmbeddedNode.invalidServerUrl' - )} - - )} - this.saveSettings(vssServer)} + onPress={() => + this.saveSettings(effectiveServer) + } /> )} @@ -175,7 +162,8 @@ export default class VssServer extends React.Component< secondary onPress={async () => { this.setState({ - vssServer: DEFAULT_VSS_SERVER + selectedValue: DEFAULT_VSS_SERVER, + customServer: '' }); await this.saveSettings( DEFAULT_VSS_SERVER diff --git a/views/Settings/LdkRecovery/LdkRecoveryVssServer.tsx b/views/Settings/LdkRecovery/LdkRecoveryVssServer.tsx new file mode 100644 index 000000000..6b64c27cd --- /dev/null +++ b/views/Settings/LdkRecovery/LdkRecoveryVssServer.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { Route } from '@react-navigation/native'; + +import Button from '../../../components/Button'; +import Header from '../../../components/Header'; +import Screen from '../../../components/Screen'; +import Text from '../../../components/Text'; +import VssServerPicker, { + resolveVssServer, + isVssServerValid +} from '../../../components/VssServerPicker'; + +import { themeColor } from '../../../utils/ThemeUtils'; +import { localeString } from '../../../utils/LocaleUtils'; +import { DEFAULT_VSS_SERVER } from '../../../utils/LdkNodeUtils'; + +interface LdkRecoveryVssServerProps { + navigation: NativeStackNavigationProp; + route: Route< + 'LdkRecoveryVssServer', + { + network: string; + nickname?: string; + photo?: string; + wordCount: 12 | 24; + } + >; +} + +interface LdkRecoveryVssServerState { + selectedValue: string; + customServer: string; +} + +export default class LdkRecoveryVssServer extends React.Component< + LdkRecoveryVssServerProps, + LdkRecoveryVssServerState +> { + state = { + selectedValue: DEFAULT_VSS_SERVER, + customServer: '' + }; + + private continueToRecovery = () => { + const { navigation, route } = this.props; + const { network, nickname, photo, wordCount } = route.params ?? {}; + const { selectedValue, customServer } = this.state; + + navigation.navigate('SeedRecovery', { + network, + implementation: 'ldk-node', + nickname, + photo, + wordCount, + vssServer: + resolveVssServer(selectedValue, customServer) || undefined + }); + }; + + render() { + const { navigation } = this.props; + const { selectedValue, customServer } = this.state; + + return ( + +
+ + + {localeString( + 'views.Settings.EmbeddedNode.VssServer.subtitle' + )} + + + + this.setState({ + selectedValue: value, + customServer: custom + }) + } + /> + + +