feat(app): label watch-only send flow and export unsigned PSBT

Send already refused to enable Confirm for watch-only accounts (Signed
false), but the App had no way to get the resulting PSBT out to sign
elsewhere — only the CLI printed it. Adds a base64 PSBT box + copy button
in the Send summary card, and a visible warning banner explaining the
wallet cannot sign, on both desktop and mobile layouts.
This commit is contained in:
2026-07-19 12:39:17 +02:00
parent f0fb5bfcc6
commit 9b00002e39
5 changed files with 65 additions and 2 deletions
+4
View File
@@ -275,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", "未签名 PSBTbase64)— 请在别处签名"],
["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)
@@ -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;
+37 -2
View File
@@ -445,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 -->
@@ -516,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"/>
@@ -620,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}"
+11
View File
@@ -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)