feat(app): add watch-address import step to setup wizard

New wizard flow mirroring the existing xpub/WIF import steps: paste one
or more plain addresses (no key material at all) and go straight to the
password step, since there is no derivation and no script-type ambiguity
to resolve for a fixed address list.
This commit is contained in:
2026-07-19 12:38:54 +02:00
parent 9fa5440ae5
commit f0fb5bfcc6
3 changed files with 95 additions and 8 deletions
+13
View File
@@ -102,6 +102,7 @@ public sealed class Loc
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"],
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"],
["wiz.importaddress.btn"] = ["Importa indirizzo (sola lettura)", "Import address (watch-only)", "Importar dirección (solo lectura)", "Importer une adresse (lecture seule)", "Importar endereço (somente leitura)", "Adresse importieren (nur lesend)", "导入地址(仅观察)"],
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"],
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)", "文件密码(未设置则留空)"],
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen", "打开"],
@@ -172,12 +173,24 @@ public sealed class Loc
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten.",
"粘贴一个或多个 WIF 密钥(每行一个)。您可以导入多个密钥,用同一个钱包控制多个地址。"],
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)", "K… / L… / 5…(每行一个密钥)"],
["wiz.importaddress.title"] = ["Importa indirizzi in sola lettura", "Import watch-only addresses", "Importar direcciones de solo lectura", "Importer des adresses en lecture seule", "Importar endereços somente leitura", "Nur-Lese-Adressen importieren", "导入仅观察地址"],
["wiz.importaddress.hint"] = [
"Incolla uno o più indirizzi (uno per riga). Nessuna chiave privata è coinvolta: potrai vedere saldo e cronologia ma non potrai mai firmare o inviare transazioni da questo wallet.",
"Paste one or more addresses (one per line). No private key is involved: you'll be able to see the balance and history but you can never sign or send transactions from this wallet.",
"Pega una o más direcciones (una por línea). No hay ninguna clave privada involucrada: podrás ver el saldo y el historial, pero nunca podrás firmar ni enviar transacciones desde este wallet.",
"Collez une ou plusieurs adresses (une par ligne). Aucune clé privée n'est impliquée : vous pourrez voir le solde et l'historique, mais vous ne pourrez jamais signer ni envoyer de transactions depuis ce wallet.",
"Cole um ou mais endereços (um por linha). Nenhuma chave privada está envolvida: você poderá ver o saldo e o histórico, mas nunca poderá assinar ou enviar transações a partir desta carteira.",
"Fügen Sie eine oder mehrere Adressen ein (eine pro Zeile). Es ist kein privater Schlüssel beteiligt: Sie können den Kontostand und den Verlauf sehen, aber nie Transaktionen aus diesem Wallet signieren oder senden.",
"粘贴一个或多个地址(每行一个)。不涉及任何私钥:您可以查看余额和历史记录,但永远无法从此钱包签名或发送交易。"],
["wiz.importaddress.placeholder"] = ["Indirizzo… (uno per riga)", "Address… (one per line)", "Dirección… (una por línea)", "Adresse… (une par ligne)", "Endereço… (um por linha)", "Adresse… (eine pro Zeile)", "地址…(每行一个)"],
// Import error messages
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante).", "请粘贴扩展密钥(xpub/xprv 或其变体)。"],
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt.", "此网络无法识别该扩展密钥。"],
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein.", "请至少粘贴一个 WIF 密钥。"],
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig.", "该 WIF 密钥对此网络无效。"],
["msg.address.required"] = ["Incolla almeno un indirizzo.", "Paste at least one address.", "Pega al menos una dirección.", "Collez au moins une adresse.", "Cole pelo menos um endereço.", "Fügen Sie mindestens eine Adresse ein.", "请至少粘贴一个地址。"],
["msg.address.invalid"] = ["Indirizzo non valido per questa rete.", "Address not valid for this network.", "Dirección no válida para esta red.", "Adresse invalide pour ce réseau.", "Endereço inválido para esta rede.", "Adresse für dieses Netzwerk ungültig.", "该地址对此网络无效。"],
// Wallet panel
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
@@ -27,9 +27,10 @@ public partial class MainWindowViewModel
public const string StepScriptType = "script-type";
public const string StepImportXkey = "import-xkey";
public const string StepImportWif = "import-wif";
public const string StepImportAddress = "import-address";
public const string StepPassword = "password";
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress }
private WizardFlowKind _wizardFlow;
[ObservableProperty]
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
[NotifyPropertyChangedFor(nameof(IsStepImportAddress))]
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart;
@@ -56,9 +58,10 @@ public partial class MainWindowViewModel
public bool IsStepWords => SetupStep == StepWords;
public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepScriptType => SetupStep == StepScriptType;
public bool IsStepImportXkey => SetupStep == StepImportXkey;
public bool IsStepImportWif => SetupStep == StepImportWif;
public bool IsStepPassword => SetupStep == StepPassword;
public bool IsStepImportXkey => SetupStep == StepImportXkey;
public bool IsStepImportWif => SetupStep == StepImportWif;
public bool IsStepImportAddress => SetupStep == StepImportAddress;
public bool IsStepPassword => SetupStep == StepPassword;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLegacySelected))]
@@ -85,6 +88,9 @@ public partial class MainWindowViewModel
[ObservableProperty]
private string importWifInput = "";
[ObservableProperty]
private string importAddressInput = "";
// Script type detected during xkey decoding (to display to the user)
[ObservableProperty]
private string importXkeyDetectedKind = "";
@@ -214,6 +220,15 @@ public partial class MainWindowViewModel
StatusMessage = "";
}
[RelayCommand]
private void WizardStartImportAddress()
{
_wizardFlow = WizardFlowKind.ImportAddress;
ImportAddressInput = "";
SetupStep = StepImportAddress;
StatusMessage = "";
}
[RelayCommand]
private void WizardNextFromShowSeed()
{
@@ -312,6 +327,35 @@ public partial class MainWindowViewModel
StatusMessage = "";
}
[RelayCommand]
private void WizardNextFromImportAddress()
{
var addresses = ImportAddressInput.Split('\n',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (addresses.Length == 0)
{
StatusMessage = Loc.Tr("msg.address.required");
return;
}
var network = PalladiumNetworks.For(Net);
foreach (var a in addresses)
{
try
{
_ = NBitcoin.BitcoinAddress.Create(a, network);
}
catch
{
StatusMessage = Loc.Tr("msg.address.invalid");
return;
}
}
PasswordInput = ConfirmPasswordInput = "";
EncryptWallet = true;
SetupStep = StepPassword;
StatusMessage = "";
}
[RelayCommand]
private void WizardNextFromScriptType()
{
@@ -327,11 +371,16 @@ public partial class MainWindowViewModel
SetupStep = SetupStep switch
{
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart,
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif or StepImportAddress => StepStart,
StepConfirmSeed => StepShowSeed,
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
StepPassword => _wizardFlow switch
{
WizardFlowKind.ImportXkey => StepImportXkey,
WizardFlowKind.ImportAddress => StepImportAddress,
_ => StepScriptType,
},
_ => StepStart,
};
if (SetupStep == StepStart)
@@ -391,6 +440,14 @@ public partial class MainWindowViewModel
(doc, account) = (d, a);
break;
}
case WizardFlowKind.ImportAddress:
{
var addressLines = ImportAddressInput.Split('\n',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var (d, a) = WalletLoader.NewFromAddresses(addressLines, Profile);
(doc, account) = (d, a);
break;
}
default:
{
var (d, a) = WalletLoader.NewFromMnemonic(
@@ -497,13 +554,14 @@ public partial class MainWindowViewModel
_walletPath = path;
_password = password;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = "";
SetupStep = StepStart;
OnPropertyChanged(nameof(IsWatchOnlyAccount));
var walletKindTag = account switch
{
ImportedKeyAccount => " · imported",
{ IsWatchOnly: true } => " · watch-only",
ImportedKeyAccount => " · imported",
_ => ""
};
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
+16
View File
@@ -99,6 +99,9 @@
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartImportWifCommand}"/>
<Button Content="{Binding Loc[wiz.importaddress.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartImportAddressCommand}"/>
</StackPanel>
<!-- Step: choose wallet (multiple files present) -->
@@ -212,6 +215,19 @@
</StackPanel>
</StackPanel>
<!-- Step: import watch-only addresses (no key at all) -->
<StackPanel IsVisible="{Binding IsStepImportAddress}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.importaddress.title]}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding Loc[wiz.importaddress.hint]}" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBox PlaceholderText="{Binding Loc[wiz.importaddress.placeholder]}"
Text="{Binding ImportAddressInput}" AcceptsReturn="True" Height="80"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromImportAddressCommand}"/>
</StackPanel>
</StackPanel>
<!-- Step: choose script/address type -->
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>