Merge branch 'watch-only'
Adds a pure address-only watch-only wallet mode (Core + CLI + App wizard + Send UI), on top of the existing xpub/xprv/WIF import flows which are unchanged. TransactionFactory already refuses to sign for any IsWatchOnly account, so the new address-only accounts inherit that guarantee for free.
This commit is contained in:
+2
-2
@@ -62,7 +62,7 @@ a sync, never a partial one, so no unverified data survives a restart.
|
||||
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock
|
||||
- Private keys are never written to disk, logged, or sent over the network
|
||||
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
|
||||
- Watch-only wallets (`restore-xpub`) hold no private keys and cannot sign transactions
|
||||
- Watch-only wallets (`restore-xpub`, or `restore-address` for pure address imports) hold no private keys and cannot sign transactions; `TransactionFactory` only calls `AddKeys`/signs when the account reports a private key, so an address-only import can never produce a signed transaction, only an exportable unsigned PSBT
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@ Connections to the indexing server use TOFU (Trust On First Use): the server's T
|
||||
|
||||
## Backup
|
||||
|
||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device.
|
||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets (restored from xpub or from a plain address), the private keys must be kept in a separate cold storage device.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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", "关闭钱包"],
|
||||
@@ -262,6 +275,10 @@ public sealed class Loc
|
||||
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger", "收款人"],
|
||||
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr", "金额与手续费"],
|
||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung", "摘要"],
|
||||
["send.psbt.label"] = ["PSBT non firmata (base64) — da firmare altrove", "Unsigned PSBT (base64) — sign it elsewhere", "PSBT sin firmar (base64) — fírmala en otro lugar", "PSBT non signée (base64) — à signer ailleurs", "PSBT não assinada (base64) — assine em outro lugar", "Unsignierte PSBT (base64) — anderswo signieren", "未签名 PSBT(base64)— 请在别处签名"],
|
||||
["send.psbt.copy"] = ["Copia PSBT", "Copy PSBT", "Copiar PSBT", "Copier la PSBT", "Copiar PSBT", "PSBT kopieren", "复制 PSBT"],
|
||||
["send.watchonly.hint"] = ["Wallet in sola lettura: non può firmare. Esporta la PSBT e firmala con un wallet che possiede le chiavi.", "Watch-only wallet: it cannot sign. Export the PSBT and sign it with a wallet that holds the keys.", "Wallet de solo lectura: no puede firmar. Exporta la PSBT y fírmala con un wallet que tenga las claves.", "Wallet en lecture seule : il ne peut pas signer. Exportez la PSBT et signez-la avec un wallet possédant les clés.", "Carteira somente leitura: não pode assinar. Exporte a PSBT e assine-a com uma carteira que tenha as chaves.", "Nur-Lese-Wallet: kann nicht signieren. Exportieren Sie die PSBT und signieren Sie sie mit einem Wallet, das die Schlüssel besitzt.", "仅观察钱包:无法签名。请导出 PSBT 并用持有密钥的钱包签名。"],
|
||||
["psbt.copied"] = ["PSBT copiata negli appunti", "PSBT copied to clipboard", "PSBT copiada al portapapeles", "PSBT copiée dans le presse-papiers", "PSBT copiada para a área de transferência", "PSBT in die Zwischenablage kopiert", "PSBT 已复制到剪贴板"],
|
||||
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse", "您的地址"],
|
||||
|
||||
// Wallet info overlay
|
||||
|
||||
@@ -31,6 +31,14 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private bool hasPendingSend;
|
||||
|
||||
[ObservableProperty]
|
||||
private string pendingPsbtBase64 = "";
|
||||
|
||||
/// <summary>True when the open account cannot sign — Send only prepares an unsigned PSBT to export.</summary>
|
||||
public bool IsWatchOnlyAccount => _account?.IsWatchOnly ?? false;
|
||||
|
||||
public void NotifyPsbtCopied() => StatusMessage = Loc.Tr("psbt.copied");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ScanQr()
|
||||
{
|
||||
@@ -81,11 +89,13 @@ public partial class MainWindowViewModel
|
||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
||||
HasPendingSend = _pendingSend.Signed;
|
||||
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
@@ -103,6 +113,7 @@ public partial class MainWindowViewModel
|
||||
SendTo = SendAmount = "";
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -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}" : "";
|
||||
|
||||
@@ -201,6 +201,8 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_lastTransactions = null;
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
OnPropertyChanged(nameof(IsWatchOnlyAccount));
|
||||
History.Clear();
|
||||
Contacts.Clear();
|
||||
SelectedContactInList = null;
|
||||
|
||||
@@ -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"/>
|
||||
@@ -429,7 +445,7 @@
|
||||
|
||||
<!-- ── DESKTOP: Recipient | Amount side-by-side ── -->
|
||||
<Grid IsVisible="{Binding IsDesktop}"
|
||||
RowDefinitions="Auto,Auto,Auto" RowSpacing="14">
|
||||
RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="14">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||
<!-- Recipient card -->
|
||||
@@ -500,11 +516,29 @@
|
||||
<SelectableTextBlock Text="{Binding SendPreview}"
|
||||
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<StackPanel Spacing="6"
|
||||
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
|
||||
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
|
||||
AcceptsReturn="True" Height="90"/>
|
||||
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||
Content="{Binding Loc[send.psbt.copy]}"
|
||||
Click="OnCopyPsbtClick"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Watch-only notice -->
|
||||
<TextBlock Grid.Row="2" Text="{Binding Loc[send.watchonly.hint]}"
|
||||
IsVisible="{Binding IsWatchOnlyAccount}"
|
||||
TextWrapping="Wrap" FontSize="12"
|
||||
Foreground="{DynamicResource WarningBrush}"/>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
|
||||
Command="{Binding PrepareSendCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
@@ -604,9 +638,26 @@
|
||||
<SelectableTextBlock Text="{Binding SendPreview}"
|
||||
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<StackPanel Spacing="6"
|
||||
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
|
||||
<TextBox Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
|
||||
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
|
||||
AcceptsReturn="True" Height="90"/>
|
||||
<Button Content="{Binding Loc[send.psbt.copy]}"
|
||||
Click="OnCopyPsbtClick"
|
||||
MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Watch-only notice -->
|
||||
<TextBlock Text="{Binding Loc[send.watchonly.hint]}"
|
||||
IsVisible="{Binding IsWatchOnlyAccount}"
|
||||
TextWrapping="Wrap" FontSize="12"
|
||||
Foreground="{DynamicResource WarningBrush}"/>
|
||||
|
||||
<!-- Action buttons: primary (Confirm) prominent, secondary below -->
|
||||
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||
Command="{Binding ConfirmSendCommand}"
|
||||
|
||||
@@ -102,6 +102,17 @@ public partial class MainView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCopyPsbtClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.PendingPsbtBase64))
|
||||
return;
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||
{
|
||||
await clipboard.SetTextAsync(vm.PendingPsbtBase64);
|
||||
vm.NotifyPsbtCopied();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
|
||||
@@ -18,6 +18,7 @@ try
|
||||
["create", .. var rest] => Create(rest),
|
||||
["restore", var words, .. var rest] => Restore(words, rest),
|
||||
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
|
||||
["restore-address", var addrs, .. var rest] => RestoreAddress(addrs, rest),
|
||||
["info", .. var rest] => Info(rest),
|
||||
["sync", .. var rest] => await Sync(rest),
|
||||
["send", .. var rest] => await Send(rest),
|
||||
@@ -95,6 +96,31 @@ static int RestoreXpub(string xpubText, string[] o)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int RestoreAddress(string addrsText, string[] o)
|
||||
{
|
||||
var profile = Profile(o);
|
||||
var addresses = addrsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("At least one address is required.");
|
||||
return 1;
|
||||
}
|
||||
WalletDocument doc;
|
||||
try
|
||||
{
|
||||
(doc, _) = WalletLoader.NewFromAddresses(addresses, profile);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
Console.WriteLine($"Watch-only wallet saved to {path} ({addresses.Length} address(es), cannot sign)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int Info(string[] o)
|
||||
{
|
||||
var (doc, account, path) = OpenWallet(o);
|
||||
@@ -354,6 +380,7 @@ static int Usage()
|
||||
[--passphrase W] [--password P] [--file PATH]
|
||||
restore "<mnemonic>" [same options as create] [--path m/...]
|
||||
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||
restore-address <addr1,addr2,...> [--net ...] [--password P] [--file PATH] (watch-only, no keys)
|
||||
info [--net ...] [--password P] [--file PATH]
|
||||
|
||||
Network (indexing server; without --server the first known server is used):
|
||||
|
||||
@@ -52,6 +52,19 @@ public static class DerivationPaths
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort reverse mapping from an already-known address to a ScriptKind, used to
|
||||
/// label pure address imports (no derivation involved, so this is informational only).
|
||||
/// </summary>
|
||||
public static ScriptKind KindFor(BitcoinAddress address) => address switch
|
||||
{
|
||||
BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit,
|
||||
TaprootAddress => ScriptKind.Taproot,
|
||||
BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig,
|
||||
BitcoinScriptAddress => ScriptKind.WrappedSegwit,
|
||||
_ => ScriptKind.Legacy,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
||||
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
||||
|
||||
@@ -40,6 +40,9 @@ public sealed class WalletDocument
|
||||
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
||||
public List<string>? WifKeys { get; set; }
|
||||
|
||||
/// <summary>Watch-only addresses with no associated private key (pure address import).</summary>
|
||||
public List<string>? WatchAddresses { get; set; }
|
||||
|
||||
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
||||
public int GapLimit { get; set; } = 20;
|
||||
|
||||
|
||||
@@ -51,7 +51,15 @@ public static class WalletLoader
|
||||
return new ImportedKeyAccount(entries, kind, profile);
|
||||
}
|
||||
|
||||
// 4. Watch-only from xpub
|
||||
// 4. Watch-only imported addresses (no keys, no HD derivation)
|
||||
if (doc.WatchAddresses is { Count: > 0 } watchAddresses)
|
||||
{
|
||||
var entries = watchAddresses.Select(a =>
|
||||
(BitcoinAddress.Create(a.Trim(), network), (Key?)null)).ToList();
|
||||
return new ImportedKeyAccount(entries, kind, profile);
|
||||
}
|
||||
|
||||
// 5. Watch-only from xpub
|
||||
if (doc.AccountXpub is null)
|
||||
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||
@@ -170,4 +178,47 @@ public static class WalletLoader
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the document from one or more plain addresses, with no private key at all
|
||||
/// (pure watch-only import — the account can never sign, unlike xpub- or WIF-based accounts
|
||||
/// which can be upgraded later by supplying the matching key). ScriptKind is informational
|
||||
/// only here (no derivation happens from a fixed address list) and is auto-detected from the
|
||||
/// first address unless <paramref name="kindOverride"/> is given.
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses(
|
||||
IReadOnlyList<string> addresses, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||
{
|
||||
if (addresses.Count == 0)
|
||||
throw new InvalidDataException("At least one address is required.");
|
||||
|
||||
var network = PalladiumNetworks.For(profile.Kind);
|
||||
var entries = new List<(BitcoinAddress, Key?)>();
|
||||
var addressStrings = new List<string>();
|
||||
|
||||
foreach (var raw in addresses)
|
||||
{
|
||||
BitcoinAddress addr;
|
||||
try
|
||||
{
|
||||
addr = BitcoinAddress.Create(raw.Trim(), network);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid address: {ex.Message}");
|
||||
}
|
||||
entries.Add((addr, null));
|
||||
addressStrings.Add(raw.Trim());
|
||||
}
|
||||
|
||||
var kind = kindOverride ?? DerivationPaths.KindFor(entries[0].Item1);
|
||||
var account = new ImportedKeyAccount(entries, kind, profile);
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = profile.NetName,
|
||||
ScriptKind = kind.ToString(),
|
||||
WatchAddresses = addressStrings,
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,21 @@ public class StorageTests
|
||||
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WatchAddresses_fa_roundtrip_json_ed_e_watch_only()
|
||||
{
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = "mainnet",
|
||||
ScriptKind = "NativeSegwit",
|
||||
WatchAddresses = ["bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"],
|
||||
};
|
||||
var restored = WalletDocument.FromJson(doc.ToJson());
|
||||
|
||||
Assert.Equal(doc.WatchAddresses, restored.WatchAddresses);
|
||||
Assert.True(restored.IsWatchOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Json_corrotto_lancia_eccezione()
|
||||
{
|
||||
|
||||
@@ -250,6 +250,33 @@ public class TransactionFactoryTests
|
||||
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_account_di_soli_indirizzi_watch_only_produce_una_psbt_non_firmata()
|
||||
{
|
||||
var full = Account();
|
||||
var (utxos, txs) = Fund(full, 1_000_000);
|
||||
|
||||
// Pure address import: no private key at all (unlike xpub watch-only, which
|
||||
// can still derive public keys/scriptPubKeys — this account can't upgrade
|
||||
// to spendable without the user separately importing the matching key).
|
||||
var watchOnly = new ImportedKeyAccount(
|
||||
[(full.GetReceiveAddress(0), (Key?)null)], ScriptKind.NativeSegwit, Profile);
|
||||
|
||||
var built = new TransactionFactory(watchOnly).Build(
|
||||
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
Assert.False(built.Signed);
|
||||
Assert.True(watchOnly.IsWatchOnly);
|
||||
Assert.Null(watchOnly.GetPrivateKey(false, 0));
|
||||
|
||||
var psbt = built.Psbt;
|
||||
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
|
||||
psbt.Finalize();
|
||||
var tx = psbt.ExtractTransaction();
|
||||
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.00000001", 1L)]
|
||||
[InlineData("1", 100_000_000L)]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
@@ -249,4 +250,85 @@ public class WalletLoaderTests
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
// ---- NewFromAddresses (pure watch-only, no key at all) ----
|
||||
|
||||
private static string SampleAddress(ScriptKind kind = ScriptKind.NativeSegwit) =>
|
||||
WalletLoader.NewFromMnemonic(ValidMnemonic, null, kind, ChainProfiles.Mainnet)
|
||||
.Account.GetReceiveAddress(0).ToString();
|
||||
|
||||
[Fact]
|
||||
public void NewFromAddresses_crea_documento_watch_only_senza_chiavi()
|
||||
{
|
||||
var address = SampleAddress();
|
||||
var (doc, account) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet);
|
||||
|
||||
Assert.Equal("mainnet", doc.Network);
|
||||
Assert.Null(doc.Mnemonic);
|
||||
Assert.Null(doc.AccountXprv);
|
||||
Assert.Null(doc.AccountXpub);
|
||||
Assert.Null(doc.WifKeys);
|
||||
Assert.Equal([address], doc.WatchAddresses);
|
||||
Assert.True(doc.IsWatchOnly);
|
||||
Assert.True(account.IsWatchOnly);
|
||||
Assert.Null(account.GetPrivateKey(false, 0));
|
||||
Assert.Equal(address, account.GetReceiveAddress(0).ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromAddresses_rileva_lo_scriptkind_dallindirizzo()
|
||||
{
|
||||
var legacyAddr = SampleAddress(ScriptKind.Legacy);
|
||||
var (doc, _) = WalletLoader.NewFromAddresses([legacyAddr], ChainProfiles.Mainnet);
|
||||
Assert.Equal("Legacy", doc.ScriptKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromAddresses_piu_indirizzi_sono_tutti_scansionabili()
|
||||
{
|
||||
var addr1 = SampleAddress();
|
||||
var addr2 = WalletLoader.NewFromMnemonic(ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
|
||||
.Account.GetReceiveAddress(0).ToString();
|
||||
var (_, account) = WalletLoader.NewFromAddresses([addr1, addr2], ChainProfiles.Mainnet);
|
||||
|
||||
Assert.NotNull(account.FixedAddresses);
|
||||
Assert.Equal(2, account.FixedAddresses!.Count);
|
||||
Assert.True(account.FixedAddresses!.All(e => account.GetPrivateKey(e.IsChange, e.Index) is null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromAddresses_lista_vuota_lancia_eccezione()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromAddresses([], ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromAddresses_indirizzo_invalido_lancia_eccezione()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromAddresses(["not-an-address"], ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromAddresses_indirizzo_di_rete_sbagliata_lancia_eccezione()
|
||||
{
|
||||
var testnetAddr = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Testnet)
|
||||
.Account.GetReceiveAddress(0).ToString();
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromAddresses([testnetAddr], ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAccount_da_watch_addresses_ricostruisce_lo_stesso_account()
|
||||
{
|
||||
var address = SampleAddress();
|
||||
var (doc, _) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet);
|
||||
|
||||
var account = WalletLoader.ToAccount(doc);
|
||||
|
||||
Assert.True(account.IsWatchOnly);
|
||||
Assert.Equal(address, account.GetReceiveAddress(0).ToString());
|
||||
Assert.Null(account.GetPrivateKey(false, 0));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user