Compare commits
13 Commits
4c0edde4f7
...
14ae39c5aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ae39c5aa | |||
| 3054a9baaa | |||
| 4dc37f1b42 | |||
| 47b6064964 | |||
| 002c854497 | |||
| 8b960458ee | |||
| 1b784a6c73 | |||
| 200c12651b | |||
| 5f983ca84e | |||
| 53ecd701c0 | |||
| bfee0dde03 | |||
| 8476eeb247 | |||
| 1914d9462b |
@@ -1,11 +1,12 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
using Android.App;
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
|
using Android.OS;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
// Activity di avvio. La configurazione dell'app (tipo App, font) è nella
|
|
||||||
// MainApplication (AvaloniaAndroidApplication<App>). Qui basta il launcher.
|
|
||||||
[Activity(
|
[Activity(
|
||||||
Label = "Palladium Wallet",
|
Label = "Palladium Wallet",
|
||||||
Theme = "@style/MyTheme.NoActionBar",
|
Theme = "@style/MyTheme.NoActionBar",
|
||||||
@@ -13,4 +14,24 @@ namespace PalladiumWallet.Mobile;
|
|||||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||||
public class MainActivity : AvaloniaMainActivity
|
public class MainActivity : AvaloniaMainActivity
|
||||||
{
|
{
|
||||||
|
internal const int ScanRequestCode = 9001;
|
||||||
|
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||||
|
internal static MainActivity? Current;
|
||||||
|
|
||||||
|
protected override void OnCreate(Bundle? savedInstanceState)
|
||||||
|
{
|
||||||
|
base.OnCreate(savedInstanceState);
|
||||||
|
Current = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||||
|
{
|
||||||
|
base.OnActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode == ScanRequestCode)
|
||||||
|
{
|
||||||
|
var text = resultCode == Result.Ok ? data?.GetStringExtra(ScannerActivity.ResultKey) : null;
|
||||||
|
ScanTcs?.TrySetResult(text);
|
||||||
|
ScanTcs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Android.App;
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
using Android.Runtime;
|
using Android.Runtime;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using PalladiumWallet.App.Services;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
@@ -10,7 +13,8 @@ namespace PalladiumWallet.Mobile;
|
|||||||
// In Avalonia 12 l'AppBuilder Android si configura nella sottoclasse Application
|
// In Avalonia 12 l'AppBuilder Android si configura nella sottoclasse Application
|
||||||
// (AvaloniaAndroidApplication<TApp>), non più nell'Activity. allowBackup=false:
|
// (AvaloniaAndroidApplication<TApp>), non più nell'Activity. allowBackup=false:
|
||||||
// il file wallet cifrato/seed non deve finire nei backup cloud automatici.
|
// il file wallet cifrato/seed non deve finire nei backup cloud automatici.
|
||||||
[Application(Label = "Palladium Wallet", AllowBackup = false)]
|
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
||||||
|
Icon = "@mipmap/ic_launcher", RoundIcon = "@mipmap/ic_launcher_round")]
|
||||||
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
||||||
{
|
{
|
||||||
public MainApplication(IntPtr javaReference, JniHandleOwnership transfer)
|
public MainApplication(IntPtr javaReference, JniHandleOwnership transfer)
|
||||||
@@ -20,10 +24,21 @@ public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWalle
|
|||||||
|
|
||||||
public override void OnCreate()
|
public override void OnCreate()
|
||||||
{
|
{
|
||||||
// Storage sandbox dell'app: wallet, configurazione e certificati vivono
|
|
||||||
// qui. Impostato prima dell'init Avalonia (che crea il ViewModel e decide
|
|
||||||
// se mostrare lo step "scegli cartella dati" del wizard).
|
|
||||||
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
||||||
|
|
||||||
|
// Registra lo scanner QR: apre ScannerActivity e ne attende il risultato.
|
||||||
|
PlatformServices.ScanQrAsync = async () =>
|
||||||
|
{
|
||||||
|
var activity = MainActivity.Current;
|
||||||
|
if (activity == null) return null;
|
||||||
|
var tcs = new TaskCompletionSource<string?>();
|
||||||
|
MainActivity.ScanTcs = tcs;
|
||||||
|
activity.StartActivityForResult(
|
||||||
|
new Intent(activity, typeof(ScannerActivity)),
|
||||||
|
MainActivity.ScanRequestCode);
|
||||||
|
return await tcs.Task;
|
||||||
|
};
|
||||||
|
|
||||||
base.OnCreate();
|
base.OnCreate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
||||||
|
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -5,4 +5,6 @@
|
|||||||
[Application] su MainApplication. usesCleartextTraffic resta al default
|
[Application] su MainApplication. usesCleartextTraffic resta al default
|
||||||
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
|
using Android.Graphics;
|
||||||
|
using Android.Hardware.Camera2;
|
||||||
|
using Android.Media;
|
||||||
|
using Android.OS;
|
||||||
|
using Android.Views;
|
||||||
|
using Android.Widget;
|
||||||
|
using ZXing;
|
||||||
|
using ZXing.Common;
|
||||||
|
using AndroidResult = Android.App.Result;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activity full-screen per la scansione QR via Camera2 + ZXing.Net.
|
||||||
|
/// Torna a MainActivity via SetResult con l'extra "qr" = testo del codice.
|
||||||
|
/// </summary>
|
||||||
|
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
||||||
|
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
||||||
|
internal sealed class ScannerActivity : Activity, TextureView.ISurfaceTextureListener
|
||||||
|
{
|
||||||
|
internal const string ResultKey = "qr";
|
||||||
|
private const int PermReq = 1;
|
||||||
|
|
||||||
|
private HandlerThread? _bgThread;
|
||||||
|
private Handler? _bgHandler;
|
||||||
|
private CameraDevice? _camera;
|
||||||
|
private CameraCaptureSession? _session;
|
||||||
|
private ImageReader? _imageReader;
|
||||||
|
private SurfaceTexture? _surfaceTex;
|
||||||
|
private bool _permOk;
|
||||||
|
private volatile bool _done;
|
||||||
|
private long _lastDecode;
|
||||||
|
|
||||||
|
protected override void OnCreate(Bundle? savedInstanceState)
|
||||||
|
{
|
||||||
|
base.OnCreate(savedInstanceState);
|
||||||
|
Window!.SetFlags(WindowManagerFlags.Fullscreen, WindowManagerFlags.Fullscreen);
|
||||||
|
|
||||||
|
var root = new FrameLayout(this);
|
||||||
|
|
||||||
|
var preview = new TextureView(this) { SurfaceTextureListener = this };
|
||||||
|
root.AddView(preview, new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MatchParent, FrameLayout.LayoutParams.MatchParent));
|
||||||
|
|
||||||
|
var hint = new TextView(this) { Text = "Inquadra un codice QR" };
|
||||||
|
hint.SetTextColor(Color.White);
|
||||||
|
hint.SetBackgroundColor(Color.ParseColor("#AA000000"));
|
||||||
|
hint.SetPadding(24, 12, 24, 12);
|
||||||
|
root.AddView(hint, new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||||
|
GravityFlags.Top | GravityFlags.CenterHorizontal) { TopMargin = 60 });
|
||||||
|
|
||||||
|
var cancel = new Button(this) { Text = "Annulla" };
|
||||||
|
cancel.Click += (_, _) => Finish();
|
||||||
|
root.AddView(cancel, new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||||
|
GravityFlags.Bottom | GravityFlags.CenterHorizontal) { BottomMargin = 80 });
|
||||||
|
|
||||||
|
SetContentView(root);
|
||||||
|
|
||||||
|
_bgThread = new HandlerThread("CamBg");
|
||||||
|
_bgThread.Start();
|
||||||
|
_bgHandler = new Handler(_bgThread.Looper!);
|
||||||
|
|
||||||
|
if (CheckSelfPermission(Android.Manifest.Permission.Camera) == Android.Content.PM.Permission.Granted)
|
||||||
|
_permOk = true;
|
||||||
|
else
|
||||||
|
RequestPermissions([Android.Manifest.Permission.Camera], PermReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnRequestPermissionsResult(int req, string[] perms, Android.Content.PM.Permission[] grants)
|
||||||
|
{
|
||||||
|
_permOk = grants.Length > 0 && grants[0] == Android.Content.PM.Permission.Granted;
|
||||||
|
if (_permOk) TryOpen(); else Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { _surfaceTex = st; TryOpen(); }
|
||||||
|
public bool OnSurfaceTextureDestroyed(SurfaceTexture st) { CloseCamera(); return true; }
|
||||||
|
public void OnSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { }
|
||||||
|
public void OnSurfaceTextureUpdated(SurfaceTexture st) { }
|
||||||
|
|
||||||
|
private void TryOpen() { if (_permOk && _surfaceTex != null) OpenCamera(_surfaceTex); }
|
||||||
|
|
||||||
|
private void OpenCamera(SurfaceTexture st)
|
||||||
|
{
|
||||||
|
var mgr = (CameraManager)GetSystemService(CameraService)!;
|
||||||
|
|
||||||
|
var ids = mgr.GetCameraIdList()!;
|
||||||
|
string cameraId = ids[0];
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var chars = mgr.GetCameraCharacteristics(id)!;
|
||||||
|
if (chars.Get(CameraCharacteristics.LensFacing) is Java.Lang.Integer f
|
||||||
|
&& f.IntValue() == (int)LensFacing.Back)
|
||||||
|
{ cameraId = id; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var map = mgr.GetCameraCharacteristics(cameraId)!
|
||||||
|
.Get(CameraCharacteristics.ScalerStreamConfigurationMap)
|
||||||
|
as Android.Hardware.Camera2.Params.StreamConfigurationMap;
|
||||||
|
var allSizes = map!.GetOutputSizes((int)ImageFormatType.Jpeg)!;
|
||||||
|
var size = allSizes.OrderBy(s => Math.Abs(s.Width - 640)).First();
|
||||||
|
|
||||||
|
st.SetDefaultBufferSize(size.Width, size.Height);
|
||||||
|
_imageReader = ImageReader.NewInstance(size.Width, size.Height, ImageFormatType.Jpeg, 2);
|
||||||
|
_imageReader.SetOnImageAvailableListener(new ImageListener(this), _bgHandler);
|
||||||
|
|
||||||
|
mgr.OpenCamera(cameraId, new OpenCb(this, st), _bgHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseCamera()
|
||||||
|
{
|
||||||
|
_session?.Close(); _session = null;
|
||||||
|
_camera?.Close(); _camera = null;
|
||||||
|
_imageReader?.Close(); _imageReader = null;
|
||||||
|
_bgThread?.QuitSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void OnCameraOpened(CameraDevice dev, SurfaceTexture st)
|
||||||
|
{
|
||||||
|
_camera = dev;
|
||||||
|
var prevSurf = new Surface(st);
|
||||||
|
var capSurf = _imageReader!.Surface!;
|
||||||
|
#pragma warning disable CA1422 // CreateCaptureSession(IList,...) obsoleted on API 30; replacement needs API 28, our min is 23
|
||||||
|
dev.CreateCaptureSession([prevSurf, capSurf], new SessionCb(this, prevSurf, capSurf), _bgHandler);
|
||||||
|
#pragma warning restore CA1422
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void OnSessionReady(CameraCaptureSession session, Surface prev, Surface cap)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
var req = _camera!.CreateCaptureRequest(CameraTemplate.Preview);
|
||||||
|
req.AddTarget(prev);
|
||||||
|
req.AddTarget(cap);
|
||||||
|
session.SetRepeatingRequest(req.Build()!, null, _bgHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void TryDecode(ImageReader reader)
|
||||||
|
{
|
||||||
|
if (_done) return;
|
||||||
|
var image = reader.AcquireLatestImage();
|
||||||
|
if (image == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = SystemClock.ElapsedRealtime();
|
||||||
|
if (now - _lastDecode < 400) return; // max ~2.5 fps
|
||||||
|
_lastDecode = now;
|
||||||
|
|
||||||
|
var buf = image.GetPlanes()![0].Buffer!;
|
||||||
|
var bytes = new byte[buf.Remaining()];
|
||||||
|
buf.Get(bytes);
|
||||||
|
|
||||||
|
var bmp = BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length);
|
||||||
|
if (bmp == null) return;
|
||||||
|
int w = bmp.Width, h = bmp.Height;
|
||||||
|
var pixels = new int[w * h];
|
||||||
|
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
||||||
|
bmp.Recycle();
|
||||||
|
|
||||||
|
// ARGB int[] → RGB byte[] per ZXing.RGBLuminanceSource
|
||||||
|
var rgb = new byte[pixels.Length * 3];
|
||||||
|
for (int i = 0; i < pixels.Length; i++)
|
||||||
|
{
|
||||||
|
rgb[i * 3] = (byte)(pixels[i] >> 16);
|
||||||
|
rgb[i * 3 + 1] = (byte)(pixels[i] >> 8);
|
||||||
|
rgb[i * 3 + 2] = (byte) pixels[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = new RGBLuminanceSource(rgb, w, h, RGBLuminanceSource.BitmapFormat.RGB24);
|
||||||
|
var zreader = new BarcodeReaderGeneric { AutoRotate = true };
|
||||||
|
zreader.Options.PossibleFormats = [BarcodeFormat.QR_CODE];
|
||||||
|
zreader.Options.TryHarder = true;
|
||||||
|
var result = zreader.Decode(source);
|
||||||
|
if (result == null) return;
|
||||||
|
|
||||||
|
_done = true;
|
||||||
|
RunOnUiThread(() =>
|
||||||
|
{
|
||||||
|
var intent = new Intent().PutExtra(ResultKey, result.Text);
|
||||||
|
SetResult(AndroidResult.Ok, intent);
|
||||||
|
Finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally { image.Close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDestroy()
|
||||||
|
{
|
||||||
|
CloseCamera();
|
||||||
|
base.OnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Callback helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class OpenCb(ScannerActivity a, SurfaceTexture st) : CameraDevice.StateCallback
|
||||||
|
{
|
||||||
|
public override void OnOpened(CameraDevice cam) => a.OnCameraOpened(cam, st);
|
||||||
|
public override void OnDisconnected(CameraDevice cam) { cam.Close(); a.Finish(); }
|
||||||
|
public override void OnError(CameraDevice cam, CameraError err) { cam.Close(); a.Finish(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SessionCb(ScannerActivity a, Surface prev, Surface cap) : CameraCaptureSession.StateCallback
|
||||||
|
{
|
||||||
|
public override void OnConfigured(CameraCaptureSession s) => a.OnSessionReady(s, prev, cap);
|
||||||
|
public override void OnConfigureFailed(CameraCaptureSession s) => a.Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ImageListener(ScannerActivity a) : Java.Lang.Object, ImageReader.IOnImageAvailableListener
|
||||||
|
{
|
||||||
|
// Interface declares ImageReader? but reader is never null in practice
|
||||||
|
public void OnImageAvailable(ImageReader? reader) => a.TryDecode(reader!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,10 +75,11 @@ public sealed class Loc
|
|||||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
||||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
||||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
||||||
["wiz.net"] = ["Rete:", "Network:", "Red:", "Réseau :", "Rede:", "Netzwerk:"],
|
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
||||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
|
||||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
|
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
|
||||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
["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"],
|
||||||
|
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"],
|
||||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
|
["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.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"],
|
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
|
||||||
@@ -97,6 +98,9 @@ public sealed class Loc
|
|||||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
|
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
|
||||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
||||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
|
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
|
||||||
|
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
|
||||||
|
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)"],
|
||||||
|
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen."],
|
||||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
|
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
|
||||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
|
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
|
||||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
|
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
|
||||||
@@ -111,6 +115,42 @@ public sealed class Loc
|
|||||||
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
|
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
|
||||||
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
|
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
|
||||||
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
|
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
|
||||||
|
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen"],
|
||||||
|
["wiz.scripttype.hint"] = [
|
||||||
|
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
|
||||||
|
"Determines the address format. If unsure, use Native SegWit.",
|
||||||
|
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
|
||||||
|
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
|
||||||
|
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
|
||||||
|
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit."],
|
||||||
|
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen"],
|
||||||
|
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen"],
|
||||||
|
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen"],
|
||||||
|
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen"],
|
||||||
|
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"],
|
||||||
|
["wiz.importxkey.hint"] = [
|
||||||
|
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
|
||||||
|
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
|
||||||
|
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
|
||||||
|
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
|
||||||
|
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
|
||||||
|
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt."],
|
||||||
|
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
||||||
|
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren"],
|
||||||
|
["wiz.importwif.hint"] = [
|
||||||
|
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
|
||||||
|
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
|
||||||
|
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
|
||||||
|
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
|
||||||
|
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
|
||||||
|
"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."],
|
||||||
|
["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)"],
|
||||||
|
|
||||||
|
// Messaggi di errore import
|
||||||
|
["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)."],
|
||||||
|
["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."],
|
||||||
|
["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."],
|
||||||
|
|
||||||
// Pannello wallet
|
// Pannello wallet
|
||||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||||
@@ -183,6 +223,7 @@ public sealed class Loc
|
|||||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
||||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
||||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
||||||
|
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
||||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
||||||
|
|
||||||
// Stato connessione
|
// Stato connessione
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seam per servizi specifici della piattaforma (Android/desktop).
|
||||||
|
/// Il head Android imposta i delegate in OnCreate; desktop li lascia null.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlatformServices
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Apre lo scanner QR nativo e restituisce il testo raw del codice,
|
||||||
|
/// oppure null se l'utente annulla o lo scanner non è disponibile.
|
||||||
|
/// </summary>
|
||||||
|
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
||||||
|
}
|
||||||
@@ -186,7 +186,7 @@ public partial class MainWindowViewModel
|
|||||||
Addresses.Add(new AddressRow(_loc["addr.receive"], i,
|
Addresses.Add(new AddressRow(_loc["addr.receive"], i,
|
||||||
_account.GetReceiveAddress(i).ToString(), "—", "—",
|
_account.GetReceiveAddress(i).ToString(), "—", "—",
|
||||||
false,
|
false,
|
||||||
_account.GetPublicKey(false, i).ToHex(),
|
_account.GetPublicKey(false, i)?.ToHex() ?? "",
|
||||||
KeyWif(false, i),
|
KeyWif(false, i),
|
||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||||
return;
|
return;
|
||||||
@@ -214,7 +214,7 @@ public partial class MainWindowViewModel
|
|||||||
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
||||||
a.TxCount > 0 ? a.TxCount.ToString() : "—",
|
a.TxCount > 0 ? a.TxCount.ToString() : "—",
|
||||||
a.IsChange,
|
a.IsChange,
|
||||||
_account.GetPublicKey(a.IsChange, a.Index).ToHex(),
|
_account.GetPublicKey(a.IsChange, a.Index)?.ToHex() ?? "",
|
||||||
KeyWif(a.IsChange, a.Index),
|
KeyWif(a.IsChange, a.Index),
|
||||||
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
|
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Services;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Wallet;
|
using PalladiumWallet.Core.Wallet;
|
||||||
@@ -29,6 +30,18 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool hasPendingSend;
|
private bool hasPendingSend;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ScanQr()
|
||||||
|
{
|
||||||
|
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
||||||
|
var raw = await scan();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||||
|
// Gestisce URI tipo "palladium:ADDRESS?amount=X" estraendo solo l'indirizzo
|
||||||
|
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
||||||
|
if (address.Contains('?')) address = address.Split('?')[0];
|
||||||
|
SendTo = address.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task PrepareSend()
|
private async Task PrepareSend()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -6,6 +7,7 @@ using Avalonia.Threading;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PalladiumWallet.App.Localization;
|
using PalladiumWallet.App.Localization;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Spv;
|
using PalladiumWallet.Core.Spv;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
@@ -115,6 +117,30 @@ public partial class MainWindowViewModel
|
|||||||
return (host, port);
|
return (host, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restituisce i server da provare in ordine: prima quello selezionato nella UI
|
||||||
|
/// (o digitato manualmente), poi gli altri in KnownServers, evitando duplicati.
|
||||||
|
/// </summary>
|
||||||
|
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// 1. Server corrente (selezionato o digitato manualmente).
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedHost))
|
||||||
|
{
|
||||||
|
var key = $"{selectedHost}:{selectedPort}";
|
||||||
|
if (seen.Add(key))
|
||||||
|
yield return (selectedHost, selectedPort);
|
||||||
|
}
|
||||||
|
// 2. Altri server noti, in ordine di lista.
|
||||||
|
foreach (var s in KnownServers)
|
||||||
|
{
|
||||||
|
var p = s.PortFor(UseSsl);
|
||||||
|
var key = $"{s.Host}:{p}";
|
||||||
|
if (seen.Add(key))
|
||||||
|
yield return (s.Host, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ConnectAndSync()
|
private async Task ConnectAndSync()
|
||||||
{
|
{
|
||||||
@@ -137,18 +163,50 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (_client is null || !_client.IsConnected)
|
if (_client is null || !_client.IsConnected)
|
||||||
{
|
{
|
||||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
|
// Salva le cache prima di distruggere il synchronizer: il nuovo
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
// synchronizer userà un client diverso e deve essere ricreato,
|
||||||
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
|
// ma i dati già scaricati vengono preservati via _doc.Cache.
|
||||||
_client.NotificationReceived += OnServerNotification;
|
PersistPartialTxCache();
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
_synchronizer = null;
|
||||||
|
|
||||||
|
// Prova tutti i server noti in ordine; parte da quello selezionato
|
||||||
|
// e scorre la lista fino al primo che risponde.
|
||||||
|
var candidates = BuildServerCandidates(host, port);
|
||||||
|
Exception? lastError = null;
|
||||||
|
foreach (var (h, p) in candidates)
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
try
|
||||||
});
|
{
|
||||||
IsConnected = true;
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
_autoReconnect = true;
|
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||||
ConnectionStatus = Loc.Tr("conn.connectedto");
|
_client.NotificationReceived += OnServerNotification;
|
||||||
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
IsConnected = false;
|
||||||
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
|
});
|
||||||
|
IsConnected = true;
|
||||||
|
_autoReconnect = true;
|
||||||
|
ConnectionStatus = Loc.Tr("conn.connectedto");
|
||||||
|
// Aggiorna la UI per riflettere il server effettivamente connesso.
|
||||||
|
_syncingServerFields = true;
|
||||||
|
ServerHost = h;
|
||||||
|
ServerPort = p.ToString();
|
||||||
|
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == h)
|
||||||
|
?? SelectedKnownServer;
|
||||||
|
_syncingServerFields = false;
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastError = ex;
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError is not null)
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_account is null || _doc is null)
|
if (_account is null || _doc is null)
|
||||||
@@ -156,7 +214,14 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (_synchronizer is null)
|
if (_synchronizer is null)
|
||||||
{
|
{
|
||||||
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
|
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
|
||||||
|
// Ricarica dalla cache su disco: evita di riscaricale le tx già note
|
||||||
|
// (fondamentale per wallet con migliaia di tx storiche — previene -101).
|
||||||
|
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||||
|
_synchronizer.PreloadCaches(
|
||||||
|
_doc.Cache?.RawTxHex ?? [],
|
||||||
|
_doc.Cache?.VerifiedAt ?? [],
|
||||||
|
net);
|
||||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +231,8 @@ public partial class MainWindowViewModel
|
|||||||
var result = await _synchronizer.SyncOnceAsync();
|
var result = await _synchronizer.SyncOnceAsync();
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
|
var (rawHex, verifiedAt) = _synchronizer.ExportCaches(
|
||||||
|
PalladiumNetworks.For(_account.Profile.Kind));
|
||||||
_doc.Cache = new SyncCache
|
_doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
@@ -176,9 +243,12 @@ public partial class MainWindowViewModel
|
|||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
Utxos = [.. result.Utxos],
|
Utxos = [.. result.Utxos],
|
||||||
Addresses = [.. result.AddressRows],
|
Addresses = [.. result.AddressRows],
|
||||||
|
RawTxHex = rawHex,
|
||||||
|
VerifiedAt = verifiedAt,
|
||||||
};
|
};
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
WalletStore.Save(_doc, _walletPath!, _password);
|
||||||
ApplyCache(_doc.Cache);
|
ApplyCache(_doc.Cache);
|
||||||
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||||
} while (_resyncRequested);
|
} while (_resyncRequested);
|
||||||
@@ -194,6 +264,13 @@ public partial class MainWindowViewModel
|
|||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
StatusMessage = $"Errore: {ex.Message}";
|
||||||
|
if (_account is not null)
|
||||||
|
{
|
||||||
|
_syncFailed = true;
|
||||||
|
// Salva le tx già scaricate: al retry il synchronizer riparte
|
||||||
|
// da qui invece di ricominciare da zero (es. dopo -101).
|
||||||
|
PersistPartialTxCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -245,6 +322,28 @@ public partial class MainWindowViewModel
|
|||||||
StatusMessage = Loc.Tr("msg.certreset");
|
StatusMessage = Loc.Tr("msg.certreset");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Salva nel SyncCache le tx e le prove di Merkle già accumulate dal synchronizer,
|
||||||
|
/// anche se la sync non è ancora completa. Consente al retry successivo
|
||||||
|
/// (o al riavvio dell'app) di riprendere senza riscaricale da zero.
|
||||||
|
/// </summary>
|
||||||
|
private void PersistPartialTxCache()
|
||||||
|
{
|
||||||
|
if (_synchronizer is null || _doc is null || _walletPath is null || _account is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||||
|
var (rawHex, verifiedAt) = _synchronizer.ExportCaches(net);
|
||||||
|
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||||
|
return;
|
||||||
|
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||||
|
_doc.Cache.VerifiedAt = verifiedAt;
|
||||||
|
WalletStore.Save(_doc, _walletPath, _password);
|
||||||
|
}
|
||||||
|
catch { /* non fatale: il prossimo salvataggio completo recupererà */ }
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DisconnectAsync()
|
private async Task DisconnectAsync()
|
||||||
{
|
{
|
||||||
if (_client is { } client)
|
if (_client is { } client)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PalladiumWallet.App.Localization;
|
using PalladiumWallet.App.Localization;
|
||||||
@@ -23,8 +24,14 @@ public partial class MainWindowViewModel
|
|||||||
public const string StepConfirmSeed = "confirm-seed";
|
public const string StepConfirmSeed = "confirm-seed";
|
||||||
public const string StepWords = "words";
|
public const string StepWords = "words";
|
||||||
public const string StepPassphrase = "passphrase";
|
public const string StepPassphrase = "passphrase";
|
||||||
|
public const string StepScriptType = "script-type";
|
||||||
|
public const string StepImportXkey = "import-xkey";
|
||||||
|
public const string StepImportWif = "import-wif";
|
||||||
public const string StepPassword = "password";
|
public const string StepPassword = "password";
|
||||||
|
|
||||||
|
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
|
||||||
|
private WizardFlowKind _wizardFlow;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
|
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepStart))]
|
[NotifyPropertyChangedFor(nameof(IsStepStart))]
|
||||||
@@ -34,21 +41,54 @@ public partial class MainWindowViewModel
|
|||||||
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
||||||
private string setupStep = StepStart;
|
private string setupStep = StepStart;
|
||||||
|
|
||||||
public bool IsStepDataLocation => SetupStep == StepDataLocation;
|
public bool IsStepDataLocation => SetupStep == StepDataLocation;
|
||||||
public bool IsStepStart => SetupStep == StepStart;
|
public bool IsStepStart => SetupStep == StepStart;
|
||||||
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
|
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
|
||||||
public bool IsStepOpen => SetupStep == StepOpen;
|
public bool IsStepOpen => SetupStep == StepOpen;
|
||||||
public bool IsStepShowSeed => SetupStep == StepShowSeed;
|
public bool IsStepShowSeed => SetupStep == StepShowSeed;
|
||||||
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
|
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
|
||||||
public bool IsStepWords => SetupStep == StepWords;
|
public bool IsStepWords => SetupStep == StepWords;
|
||||||
public bool IsStepPassphrase => SetupStep == StepPassphrase;
|
public bool IsStepPassphrase => SetupStep == StepPassphrase;
|
||||||
public bool IsStepPassword => SetupStep == StepPassword;
|
public bool IsStepScriptType => SetupStep == StepScriptType;
|
||||||
|
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
||||||
|
public bool IsStepImportWif => SetupStep == StepImportWif;
|
||||||
|
public bool IsStepPassword => SetupStep == StepPassword;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsLegacySelected))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsWrappedSegwitSelected))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsNativeSegwitSelected))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsTaprootSelected))]
|
||||||
|
private ScriptKind selectedScriptKind = ScriptKind.NativeSegwit;
|
||||||
|
|
||||||
|
public bool IsLegacySelected => SelectedScriptKind == ScriptKind.Legacy;
|
||||||
|
public bool IsWrappedSegwitSelected => SelectedScriptKind == ScriptKind.WrappedSegwit;
|
||||||
|
public bool IsNativeSegwitSelected => SelectedScriptKind == ScriptKind.NativeSegwit;
|
||||||
|
public bool IsTaprootSelected => SelectedScriptKind == ScriptKind.Taproot;
|
||||||
|
|
||||||
|
[RelayCommand] private void SelectLegacy() => SelectedScriptKind = ScriptKind.Legacy;
|
||||||
|
[RelayCommand] private void SelectWrappedSegwit() => SelectedScriptKind = ScriptKind.WrappedSegwit;
|
||||||
|
[RelayCommand] private void SelectNativeSegwit() => SelectedScriptKind = ScriptKind.NativeSegwit;
|
||||||
|
[RelayCommand] private void SelectTaproot() => SelectedScriptKind = ScriptKind.Taproot;
|
||||||
|
|
||||||
public string DefaultDataPath => AppPaths.DefaultDataRoot();
|
public string DefaultDataPath => AppPaths.DefaultDataRoot();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string importXkeyInput = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string importWifInput = "";
|
||||||
|
|
||||||
|
// Tipo di script rilevato durante la decodifica dell'xkey (per mostrarlo all'utente)
|
||||||
|
[ObservableProperty]
|
||||||
|
private string importXkeyDetectedKind = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string mnemonicInput = "";
|
private string mnemonicInput = "";
|
||||||
|
|
||||||
@@ -58,6 +98,9 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string passphraseInput = "";
|
private string passphraseInput = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string walletNameInput = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string passwordInput = "";
|
private string passwordInput = "";
|
||||||
|
|
||||||
@@ -96,6 +139,7 @@ public partial class MainWindowViewModel
|
|||||||
private void RefreshSetupState()
|
private void RefreshSetupState()
|
||||||
{
|
{
|
||||||
SetupStep = StepStart;
|
SetupStep = StepStart;
|
||||||
|
SelectedScriptKind = ScriptKind.NativeSegwit;
|
||||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
|
||||||
WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
|
WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
@@ -136,7 +180,7 @@ public partial class MainWindowViewModel
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WizardStartNew()
|
private void WizardStartNew()
|
||||||
{
|
{
|
||||||
_isRestoreFlow = false;
|
_wizardFlow = WizardFlowKind.New;
|
||||||
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
||||||
SetupStep = StepShowSeed;
|
SetupStep = StepShowSeed;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
@@ -145,12 +189,31 @@ public partial class MainWindowViewModel
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WizardStartRestore()
|
private void WizardStartRestore()
|
||||||
{
|
{
|
||||||
_isRestoreFlow = true;
|
_wizardFlow = WizardFlowKind.Restore;
|
||||||
MnemonicInput = "";
|
MnemonicInput = "";
|
||||||
SetupStep = StepWords;
|
SetupStep = StepWords;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardStartImportXkey()
|
||||||
|
{
|
||||||
|
_wizardFlow = WizardFlowKind.ImportXkey;
|
||||||
|
ImportXkeyInput = "";
|
||||||
|
ImportXkeyDetectedKind = "";
|
||||||
|
SetupStep = StepImportXkey;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardStartImportWif()
|
||||||
|
{
|
||||||
|
_wizardFlow = WizardFlowKind.ImportWif;
|
||||||
|
ImportWifInput = "";
|
||||||
|
SetupStep = StepImportWif;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WizardNextFromShowSeed()
|
private void WizardNextFromShowSeed()
|
||||||
{
|
{
|
||||||
@@ -192,6 +255,65 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void WizardNextFromPassphrase()
|
private void WizardNextFromPassphrase()
|
||||||
|
{
|
||||||
|
SetupStep = StepScriptType;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardNextFromImportXkey()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ImportXkeyInput))
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.xkey.required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Valida e rileva il tipo: prova prima come xpub, poi come xprv.
|
||||||
|
if (Slip132.TryDecodePublic(ImportXkeyInput.Trim(), Profile, out _, out var pubKind))
|
||||||
|
{
|
||||||
|
SelectedScriptKind = pubKind;
|
||||||
|
ImportXkeyDetectedKind = $"{pubKind} (watch-only)";
|
||||||
|
}
|
||||||
|
else if (Slip132.TryDecodePrivate(ImportXkeyInput.Trim(), Profile, out _, out var prvKind))
|
||||||
|
{
|
||||||
|
SelectedScriptKind = prvKind;
|
||||||
|
ImportXkeyDetectedKind = prvKind.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.xkey.invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PasswordInput = ConfirmPasswordInput = "";
|
||||||
|
EncryptWallet = true;
|
||||||
|
SetupStep = StepPassword;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardNextFromImportWif()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ImportWifInput))
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.wif.required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Valida il WIF con un parsing anticipato.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new NBitcoin.BitcoinSecret(ImportWifInput.Trim(), PalladiumNetworks.For(Net));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.wif.invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SetupStep = StepScriptType;
|
||||||
|
StatusMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardNextFromScriptType()
|
||||||
{
|
{
|
||||||
PasswordInput = ConfirmPasswordInput = "";
|
PasswordInput = ConfirmPasswordInput = "";
|
||||||
EncryptWallet = true;
|
EncryptWallet = true;
|
||||||
@@ -205,10 +327,11 @@ public partial class MainWindowViewModel
|
|||||||
SetupStep = SetupStep switch
|
SetupStep = SetupStep switch
|
||||||
{
|
{
|
||||||
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
||||||
StepChooseWallet or StepShowSeed or StepWords => StepStart,
|
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart,
|
||||||
StepConfirmSeed => StepShowSeed,
|
StepConfirmSeed => StepShowSeed,
|
||||||
StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed,
|
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
|
||||||
StepPassword => StepPassphrase,
|
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
|
||||||
|
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
|
||||||
_ => StepStart,
|
_ => StepStart,
|
||||||
};
|
};
|
||||||
if (SetupStep == StepStart)
|
if (SetupStep == StepStart)
|
||||||
@@ -240,14 +363,52 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (doc, account) = WalletLoader.NewFromMnemonic(
|
WalletDocument doc;
|
||||||
MnemonicInput,
|
IWalletAccount account;
|
||||||
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
|
|
||||||
ScriptKind.NativeSegwit,
|
switch (_wizardFlow)
|
||||||
Profile);
|
{
|
||||||
var path = AppPaths.DefaultWalletPath(Net);
|
case WizardFlowKind.ImportXkey:
|
||||||
for (var n = 2; WalletStore.Exists(path); n++)
|
{
|
||||||
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
var input = ImportXkeyInput.Trim();
|
||||||
|
if (Slip132.TryDecodePublic(input, Profile, out _, out _))
|
||||||
|
{
|
||||||
|
var (d, a) = WalletLoader.NewFromXpub(input, Profile, SelectedScriptKind);
|
||||||
|
(doc, account) = (d, a);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (d, a) = WalletLoader.NewFromXprv(input, Profile, SelectedScriptKind);
|
||||||
|
(doc, account) = (d, a);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case WizardFlowKind.ImportWif:
|
||||||
|
{
|
||||||
|
var wifLines = ImportWifInput.Split('\n',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var (d, a) = WalletLoader.NewFromWif(wifLines, SelectedScriptKind, Profile);
|
||||||
|
(doc, account) = (d, a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
var (d, a) = WalletLoader.NewFromMnemonic(
|
||||||
|
MnemonicInput,
|
||||||
|
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
|
||||||
|
SelectedScriptKind,
|
||||||
|
Profile);
|
||||||
|
(doc, account) = (d, a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = WalletPathFromName(WalletNameInput);
|
||||||
|
if (WalletStore.Exists(path))
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.Tr("msg.wallet.exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
WalletStore.Save(doc, path, password);
|
WalletStore.Save(doc, path, password);
|
||||||
var newLock = WalletLock.TryAcquire(path);
|
var newLock = WalletLock.TryAcquire(path);
|
||||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
||||||
@@ -259,6 +420,31 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Costruisce il percorso file dal nome inserito dall'utente.
|
||||||
|
/// Se il nome è vuoto genera un nome automatico (default, wallet-2, …).
|
||||||
|
/// Rimuove i caratteri non validi per il filesystem.
|
||||||
|
/// </summary>
|
||||||
|
private string WalletPathFromName(string name)
|
||||||
|
{
|
||||||
|
var clean = string.IsNullOrWhiteSpace(name)
|
||||||
|
? ""
|
||||||
|
: string.Concat(name.Trim()
|
||||||
|
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c))
|
||||||
|
.Trim('.');
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(clean))
|
||||||
|
{
|
||||||
|
// Nessun nome → nome automatico
|
||||||
|
var def = AppPaths.DefaultWalletPath(Net);
|
||||||
|
for (var n = 2; WalletStore.Exists(def); n++)
|
||||||
|
def = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(AppPaths.WalletsDir(Net), $"{clean}.wallet.json");
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenExisting()
|
private void OpenExisting()
|
||||||
{
|
{
|
||||||
@@ -336,25 +522,33 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OpenLoaded(
|
private void OpenLoaded(
|
||||||
WalletDocument doc, HdAccount account, string path, string? password, WalletLock walletLock)
|
WalletDocument doc, IWalletAccount account, string path, string? password, WalletLock walletLock)
|
||||||
{
|
{
|
||||||
_walletLock?.Dispose();
|
_walletLock?.Dispose();
|
||||||
_walletLock = walletLock;
|
_walletLock = walletLock;
|
||||||
|
|
||||||
SelectedNetwork = doc.Network;
|
|
||||||
_doc = doc;
|
_doc = doc;
|
||||||
_account = account;
|
_account = account;
|
||||||
_walletPath = path;
|
_walletPath = path;
|
||||||
_password = password;
|
_password = password;
|
||||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
|
||||||
|
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
|
||||||
SetupStep = StepStart;
|
SetupStep = StepStart;
|
||||||
|
|
||||||
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
|
var walletKindTag = account switch
|
||||||
+ (doc.IsWatchOnly ? " · watch-only" : "");
|
{
|
||||||
|
ImportedKeyAccount => " · imported",
|
||||||
|
{ IsWatchOnly: true } => " · watch-only",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
|
||||||
|
NetworkInfo = $"{doc.Network} · {doc.ScriptKind}{pathTag}{walletKindTag}";
|
||||||
LoadContacts();
|
LoadContacts();
|
||||||
ApplyCache(doc.Cache);
|
ApplyCache(doc.Cache);
|
||||||
IsWalletOpen = true;
|
IsWalletOpen = true;
|
||||||
StatusMessage = Loc.Tr("msg.opened");
|
StatusMessage = Loc.Tr("msg.opened");
|
||||||
|
_autoReconnect = true; // keepalive riprova la connessione anche se il primo tentativo fallisce
|
||||||
|
_syncFailed = true; // forza la prima sync automatica
|
||||||
_ = ConnectAndSync();
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
// ---- stato sessione wallet ----
|
// ---- stato sessione wallet ----
|
||||||
private WalletDocument? _doc;
|
private WalletDocument? _doc;
|
||||||
private HdAccount? _account;
|
private IWalletAccount? _account;
|
||||||
private string? _walletPath;
|
private string? _walletPath;
|
||||||
private string? _password;
|
private string? _password;
|
||||||
private WalletLock? _walletLock;
|
private WalletLock? _walletLock;
|
||||||
@@ -73,10 +73,10 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
// ---- wizard ----
|
// ---- wizard ----
|
||||||
private string? _pendingOpenPath;
|
private string? _pendingOpenPath;
|
||||||
private bool _isRestoreFlow;
|
|
||||||
|
|
||||||
// ---- keep-alive ----
|
// ---- keep-alive ----
|
||||||
private bool _autoReconnect;
|
private bool _autoReconnect;
|
||||||
|
private bool _syncFailed;
|
||||||
private readonly DispatcherTimer _keepAliveTimer;
|
private readonly DispatcherTimer _keepAliveTimer;
|
||||||
|
|
||||||
// ---- server UI sync ----
|
// ---- server UI sync ----
|
||||||
@@ -101,13 +101,6 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
// ---- stato pannelli ----
|
// ---- stato pannelli ----
|
||||||
|
|
||||||
public string[] Networks { get; } = ["mainnet", "testnet", "regtest"];
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string selectedNetwork = "mainnet";
|
|
||||||
|
|
||||||
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
||||||
private bool isWalletOpen;
|
private bool isWalletOpen;
|
||||||
@@ -117,6 +110,12 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string statusMessage = "";
|
private string statusMessage = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int selectedTabIndex;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SelectTab(string index) => SelectedTabIndex = int.Parse(index);
|
||||||
|
|
||||||
// ---- collections per la dashboard ----
|
// ---- collections per la dashboard ----
|
||||||
|
|
||||||
public ObservableCollection<HistoryRow> History { get; } = [];
|
public ObservableCollection<HistoryRow> History { get; } = [];
|
||||||
@@ -124,7 +123,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
private NetKind Net => System.Enum.Parse<NetKind>(SelectedNetwork, ignoreCase: true);
|
private NetKind Net => NetKind.Mainnet;
|
||||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
private ChainProfile Profile => ChainProfiles.For(Net);
|
||||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
||||||
|
|
||||||
@@ -136,8 +135,8 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
if (_account is null or { IsWatchOnly: true }) return "";
|
if (_account is null or { IsWatchOnly: true }) return "";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _account.GetExtPrivateKey(isChange, index)
|
return _account.GetPrivateKey(isChange, index)
|
||||||
.PrivateKey.GetWif(PalladiumNetworks.For(Net)).ToString();
|
?.GetWif(PalladiumNetworks.For(Net)).ToString() ?? "";
|
||||||
}
|
}
|
||||||
catch { return ""; }
|
catch { return ""; }
|
||||||
}
|
}
|
||||||
@@ -162,6 +161,12 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
if (_client is { IsConnected: true })
|
if (_client is { IsConnected: true })
|
||||||
{
|
{
|
||||||
|
// Se il wallet è aperto e l'ultima sync è fallita, riprova automaticamente.
|
||||||
|
if (_syncFailed && _account is not null)
|
||||||
|
{
|
||||||
|
await ConnectAndSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try { await _client.PingAsync(); }
|
try { await _client.PingAsync(); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_synchronizer = null;
|
_synchronizer = null;
|
||||||
_autoReconnect = false;
|
_autoReconnect = false;
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
|
_syncFailed = false;
|
||||||
_doc = null;
|
_doc = null;
|
||||||
_account = null;
|
_account = null;
|
||||||
_lastTransactions = null;
|
_lastTransactions = null;
|
||||||
|
|||||||
+177
-47
@@ -12,6 +12,19 @@
|
|||||||
<vm:MainWindowViewModel/>
|
<vm:MainWindowViewModel/>
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
<!-- Su desktop gli overlay card sono centrati con dimensione massima;
|
||||||
|
su mobile il default (stretch, nessun margine) occupa tutta la schermata. -->
|
||||||
|
<Style Selector="Border.desktop-overlay">
|
||||||
|
<Setter Property="MaxWidth" Value="540"/>
|
||||||
|
<Setter Property="MaxHeight" Value="540"/>
|
||||||
|
<Setter Property="Margin" Value="16"/>
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ============ MENU (stile Electrum) ============ -->
|
<!-- ============ MENU (stile Electrum) ============ -->
|
||||||
@@ -64,11 +77,6 @@
|
|||||||
|
|
||||||
<!-- Passo 1: scelta iniziale -->
|
<!-- Passo 1: scelta iniziale -->
|
||||||
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
|
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding Loc[wiz.net]}" VerticalAlignment="Center"/>
|
|
||||||
<ComboBox ItemsSource="{Binding Networks}"
|
|
||||||
SelectedItem="{Binding SelectedNetwork}" MinWidth="140"/>
|
|
||||||
</StackPanel>
|
|
||||||
<Button Content="{Binding Loc[wiz.open.btn]}" FontSize="16"
|
<Button Content="{Binding Loc[wiz.open.btn]}" FontSize="16"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
IsVisible="{Binding WalletFileExists}"
|
IsVisible="{Binding WalletFileExists}"
|
||||||
@@ -79,6 +87,12 @@
|
|||||||
<Button Content="{Binding Loc[wiz.restore.btn]}" FontSize="16"
|
<Button Content="{Binding Loc[wiz.restore.btn]}" FontSize="16"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
Command="{Binding WizardStartRestoreCommand}"/>
|
Command="{Binding WizardStartRestoreCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.importxkey.btn]}" FontSize="16"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding WizardStartImportXkeyCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding WizardStartImportWifCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Passo: scelta del wallet (più file presenti) -->
|
<!-- Passo: scelta del wallet (più file presenti) -->
|
||||||
@@ -163,18 +177,116 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Passo finale: password del file (cifratura, stile Electrum) -->
|
<!-- Passo: import xpub/xprv estesa (SLIP-132) -->
|
||||||
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
|
<StackPanel IsVisible="{Binding IsStepImportXkey}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.importxkey.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.importxkey.hint]}" TextWrapping="Wrap" Foreground="Gray"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.importxkey.placeholder]}"
|
||||||
|
Text="{Binding ImportXkeyInput}" AcceptsReturn="False"/>
|
||||||
|
<TextBlock Text="{Binding ImportXkeyDetectedKind}"
|
||||||
|
IsVisible="{Binding ImportXkeyDetectedKind, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
Foreground="Green" FontSize="12"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||||
|
Command="{Binding WizardNextFromImportXkeyCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Passo: import chiave WIF singola -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepImportWif}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.importwif.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.importwif.hint]}" TextWrapping="Wrap" Foreground="Gray"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.importwif.placeholder]}"
|
||||||
|
Text="{Binding ImportWifInput}" 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 WizardNextFromImportWifCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Passo: scelta del tipo di script/indirizzi -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.scripttype.hint]}" TextWrapping="Wrap" Foreground="Gray"/>
|
||||||
|
|
||||||
|
<RadioButton GroupName="ScriptType"
|
||||||
|
IsChecked="{Binding IsLegacySelected, Mode=OneWay}"
|
||||||
|
Command="{Binding SelectLegacyCommand}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Legacy (P2PKH)" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.scripttype.legacy.desc]}" Foreground="Gray" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</RadioButton>
|
||||||
|
|
||||||
|
<RadioButton GroupName="ScriptType"
|
||||||
|
IsChecked="{Binding IsWrappedSegwitSelected, Mode=OneWay}"
|
||||||
|
Command="{Binding SelectWrappedSegwitCommand}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Wrapped SegWit (P2SH-P2WPKH)" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.scripttype.wrapped.desc]}" Foreground="Gray" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</RadioButton>
|
||||||
|
|
||||||
|
<RadioButton GroupName="ScriptType"
|
||||||
|
IsChecked="{Binding IsNativeSegwitSelected, Mode=OneWay}"
|
||||||
|
Command="{Binding SelectNativeSegwitCommand}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Native SegWit (P2WPKH)" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.scripttype.native.desc]}" Foreground="Gray" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</RadioButton>
|
||||||
|
|
||||||
|
<RadioButton GroupName="ScriptType"
|
||||||
|
IsChecked="{Binding IsTaprootSelected, Mode=OneWay}"
|
||||||
|
Command="{Binding SelectTaprootCommand}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Taproot (P2TR)" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.scripttype.taproot.desc]}" Foreground="Gray" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</RadioButton>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||||
|
Command="{Binding WizardNextFromScriptTypeCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Passo finale: nome + password del file (cifratura, stile Electrum) -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="16">
|
||||||
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
|
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
|
|
||||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
<!-- Sezione nome: bordo distinto dal gruppo password -->
|
||||||
<TextBox PlaceholderText="{Binding Loc[wiz.password.confirm]}"
|
<Border BorderBrush="{DynamicResource SystemAccentColor}"
|
||||||
PasswordChar="●" Text="{Binding ConfirmPasswordInput}"/>
|
BorderThickness="0,0,0,0"
|
||||||
<CheckBox Content="{Binding Loc[wiz.password.encrypt]}"
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
IsChecked="{Binding EncryptWallet}"/>
|
CornerRadius="6" Padding="12,10">
|
||||||
<TextBlock Text="{Binding Loc[wiz.password.encrypt.hint]}"
|
<StackPanel Spacing="6">
|
||||||
Foreground="Gray" FontSize="12" TextWrapping="Wrap"
|
<TextBlock Text="{Binding Loc[wiz.name.label]}"
|
||||||
IsVisible="{Binding !EncryptWallet}"/>
|
FontWeight="SemiBold" FontSize="13"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.name.placeholder]}"
|
||||||
|
Text="{Binding WalletNameInput}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Separatore visivo -->
|
||||||
|
<Separator Margin="0,0"/>
|
||||||
|
|
||||||
|
<!-- Sezione cifratura -->
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
|
||||||
|
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.password.confirm]}"
|
||||||
|
PasswordChar="●" Text="{Binding ConfirmPasswordInput}"/>
|
||||||
|
<CheckBox Content="{Binding Loc[wiz.password.encrypt]}"
|
||||||
|
IsChecked="{Binding EncryptWallet}"/>
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.password.encrypt.hint]}"
|
||||||
|
Foreground="Gray" FontSize="12" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !EncryptWallet}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
|
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
|
||||||
@@ -191,17 +303,20 @@
|
|||||||
wallet è in File. Lo stato connessione è nella barra in basso. -->
|
wallet è in File. Lo stato connessione è nella barra in basso. -->
|
||||||
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,12">
|
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,12">
|
||||||
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
|
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
|
||||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
|
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
|
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Tab: Storico / Invia / Ricevi / Indirizzi / Contatti -->
|
<!-- Tab: Storico / Invia / Ricevi / Indirizzi / Contatti -->
|
||||||
<TabControl Grid.Row="1"
|
<TabControl Grid.Row="1"
|
||||||
TabStripPlacement="{Binding IsMobile, Converter={x:Static vm:BoolToTabPlacementConverter.Instance}}">
|
SelectedIndex="{Binding SelectedTabIndex}"
|
||||||
|
TabStripPlacement="Top">
|
||||||
|
|
||||||
<TabControl.Styles>
|
<TabControl.Styles>
|
||||||
<!-- Tab equamente distribuiti su tutta la larghezza -->
|
|
||||||
<Style Selector="TabStrip">
|
<Style Selector="TabStrip">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundChromeMediumBrush}"/>
|
||||||
<Setter Property="ItemsPanel">
|
<Setter Property="ItemsPanel">
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<UniformGrid Rows="1"/>
|
<UniformGrid Rows="1"/>
|
||||||
@@ -212,7 +327,7 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
<Setter Property="Padding" Value="6,8"/>
|
<Setter Property="Padding" Value="4,8"/>
|
||||||
</Style>
|
</Style>
|
||||||
</TabControl.Styles>
|
</TabControl.Styles>
|
||||||
|
|
||||||
@@ -221,7 +336,7 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||||
<TextBlock Text="≡" FontSize="19" HorizontalAlignment="Center"
|
<TextBlock Text="≡" FontSize="19" HorizontalAlignment="Center"
|
||||||
IsVisible="{Binding IsMobile}"/>
|
/>
|
||||||
<TextBlock Text="{Binding Loc[tab.history]}" FontSize="12"
|
<TextBlock Text="{Binding Loc[tab.history]}" FontSize="12"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -270,7 +385,7 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||||
<TextBlock Text="↑" FontSize="19" HorizontalAlignment="Center"
|
<TextBlock Text="↑" FontSize="19" HorizontalAlignment="Center"
|
||||||
IsVisible="{Binding IsMobile}"/>
|
/>
|
||||||
<TextBlock Text="{Binding Loc[tab.send]}" FontSize="12"
|
<TextBlock Text="{Binding Loc[tab.send]}" FontSize="12"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -289,8 +404,22 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
FontFamily="monospace"/>
|
<TextBox Grid.Column="0"
|
||||||
|
PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
|
||||||
|
FontFamily="monospace"/>
|
||||||
|
<Button Grid.Column="1" Margin="6,0,0,0" Padding="8,6"
|
||||||
|
IsVisible="{Binding IsMobile}"
|
||||||
|
Command="{Binding ScanQrCommand}"
|
||||||
|
ToolTip.Tip="{Binding Loc[send.scan]}">
|
||||||
|
<Viewbox Width="22" Height="22">
|
||||||
|
<Canvas Width="24" Height="24">
|
||||||
|
<Path Fill="{DynamicResource SystemBaseHighColor}"
|
||||||
|
Data="M20,5H16.83L15,3H9L7.17,5H4A2,2 0 0,0 2,7V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V7A2,2 0 0,0 20,5M20,19H4V7H8.05L9.88,5H14.12L15.95,7H20V19M12,8A5,5 0 0,0 7,13A5,5 0 0,0 12,18A5,5 0 0,0 17,13A5,5 0 0,0 12,8M12,16A3,3 0 0,1 9,13A3,3 0 0,1 12,10A3,3 0 0,1 15,13A3,3 0 0,1 12,16Z"/>
|
||||||
|
</Canvas>
|
||||||
|
</Viewbox>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
<!-- Desktop: riga amount + fee in 4 colonne -->
|
<!-- Desktop: riga amount + fee in 4 colonne -->
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto" IsVisible="{Binding IsDesktop}">
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto" IsVisible="{Binding IsDesktop}">
|
||||||
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
|
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
|
||||||
@@ -335,7 +464,7 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||||
<TextBlock Text="↓" FontSize="19" HorizontalAlignment="Center"
|
<TextBlock Text="↓" FontSize="19" HorizontalAlignment="Center"
|
||||||
IsVisible="{Binding IsMobile}"/>
|
/>
|
||||||
<TextBlock Text="{Binding Loc[tab.receive]}" FontSize="12"
|
<TextBlock Text="{Binding Loc[tab.receive]}" FontSize="12"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -377,7 +506,7 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||||
<TextBlock Text="⊙" FontSize="19" HorizontalAlignment="Center"
|
<TextBlock Text="⊙" FontSize="19" HorizontalAlignment="Center"
|
||||||
IsVisible="{Binding IsMobile}"/>
|
/>
|
||||||
<TextBlock Text="{Binding Loc[tab.addresses]}" FontSize="12"
|
<TextBlock Text="{Binding Loc[tab.addresses]}" FontSize="12"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -435,7 +564,7 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||||
<TextBlock Text="⊕" FontSize="19" HorizontalAlignment="Center"
|
<TextBlock Text="⊕" FontSize="19" HorizontalAlignment="Center"
|
||||||
IsVisible="{Binding IsMobile}"/>
|
/>
|
||||||
<TextBlock Text="{Binding Loc[tab.contacts]}" FontSize="12"
|
<TextBlock Text="{Binding Loc[tab.contacts]}" FontSize="12"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -465,7 +594,8 @@
|
|||||||
<!-- Mobile: card verticale -->
|
<!-- Mobile: card verticale -->
|
||||||
<StackPanel Spacing="1" Margin="0,3"
|
<StackPanel Spacing="1" Margin="0,3"
|
||||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
|
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
|
||||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<SelectableTextBlock Text="{Binding Address}"
|
<SelectableTextBlock Text="{Binding Address}"
|
||||||
FontFamily="monospace" FontSize="11"
|
FontFamily="monospace" FontSize="11"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
@@ -506,6 +636,7 @@
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Barra di stato: messaggio a sinistra, stato connessione a destra.
|
<!-- Barra di stato: messaggio a sinistra, stato connessione a destra.
|
||||||
@@ -720,9 +851,8 @@
|
|||||||
Tapped="OnServerOverlayBackdropTapped"
|
Tapped="OnServerOverlayBackdropTapped"
|
||||||
IsVisible="{Binding IsServerSettingsOpen}">
|
IsVisible="{Binding IsServerSettingsOpen}">
|
||||||
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||||
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
|
BorderBrush="Gray" BorderThickness="1"
|
||||||
MaxWidth="540" MaxHeight="540" Margin="16"
|
Classes.desktop-overlay="{Binding IsDesktop}">
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="24" Spacing="14">
|
<StackPanel Margin="24" Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[server.title]}"
|
<TextBlock Text="{Binding Loc[server.title]}"
|
||||||
@@ -744,20 +874,20 @@
|
|||||||
<CheckBox Grid.Row="1" Grid.Column="2" IsChecked="{Binding UseSsl}"
|
<CheckBox Grid.Row="1" Grid.Column="2" IsChecked="{Binding UseSsl}"
|
||||||
Margin="10,2,0,0" VerticalAlignment="Center"/>
|
Margin="10,2,0,0" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Host + porta — Mobile: verticale -->
|
<!-- Host + porta — Mobile: verticale, font più grandi -->
|
||||||
<StackPanel Spacing="8" IsVisible="{Binding IsMobile}">
|
<StackPanel Spacing="10" IsVisible="{Binding IsMobile}">
|
||||||
<StackPanel Spacing="3">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="{Binding Loc[server.host]}" FontSize="11" Foreground="Gray"/>
|
<TextBlock Text="{Binding Loc[server.host]}" FontSize="13" Foreground="Gray"/>
|
||||||
<TextBox Text="{Binding ServerHost}" FontFamily="monospace"/>
|
<TextBox Text="{Binding ServerHost}" FontFamily="monospace" FontSize="15"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="3">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="{Binding Loc[server.port]}" FontSize="11" Foreground="Gray"/>
|
<TextBlock Text="{Binding Loc[server.port]}" FontSize="13" Foreground="Gray"/>
|
||||||
<TextBox Text="{Binding ServerPort}" FontFamily="monospace"/>
|
<TextBox Text="{Binding ServerPort}" FontFamily="monospace" FontSize="15"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Spacing="3" Margin="16,0,0,0"
|
<StackPanel Grid.Column="1" Spacing="4" Margin="20,0,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBlock Text="TLS" FontSize="11" Foreground="Gray"
|
<TextBlock Text="TLS" FontSize="13" Foreground="Gray"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
<CheckBox IsChecked="{Binding UseSsl}"
|
<CheckBox IsChecked="{Binding UseSsl}"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
@@ -801,7 +931,7 @@
|
|||||||
<TextBlock Text="{Binding Loc[server.known]}" FontSize="11" Foreground="Gray"/>
|
<TextBlock Text="{Binding Loc[server.known]}" FontSize="11" Foreground="Gray"/>
|
||||||
<ListBox ItemsSource="{Binding KnownServers}"
|
<ListBox ItemsSource="{Binding KnownServers}"
|
||||||
SelectedItem="{Binding SelectedKnownServer}"
|
SelectedItem="{Binding SelectedKnownServer}"
|
||||||
MaxHeight="200">
|
MaxHeight="300">
|
||||||
<ListBox.Styles>
|
<ListBox.Styles>
|
||||||
<Style Selector="ListBox:empty">
|
<Style Selector="ListBox:empty">
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
@@ -829,16 +959,16 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Mobile: host sopra, porte sotto -->
|
<!-- Mobile: host sopra, porte sotto -->
|
||||||
<StackPanel Spacing="1" Margin="0,3"
|
<StackPanel Spacing="2" Margin="0,4"
|
||||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
|
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
|
||||||
<TextBlock Text="{Binding Host}"
|
<TextBlock Text="{Binding Host}"
|
||||||
FontFamily="monospace" FontSize="13"
|
FontFamily="monospace" FontSize="15"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||||
<TextBlock FontSize="11" Foreground="Gray">
|
<TextBlock FontSize="12" Foreground="Gray">
|
||||||
<Run Text="tcp "/><Run Text="{Binding TcpPort}"/>
|
<Run Text="tcp "/><Run Text="{Binding TcpPort}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock FontSize="11" Foreground="Gray">
|
<TextBlock FontSize="12" Foreground="Gray">
|
||||||
<Run Text="ssl "/><Run Text="{Binding SslPort}"/>
|
<Run Text="ssl "/><Run Text="{Binding SslPort}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
+1
-1
@@ -282,7 +282,7 @@ static int SaveWallet(string words, string[] o)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static (WalletDocument, HdAccount, string) OpenWallet(string[] o)
|
static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
||||||
{
|
{
|
||||||
var path = WalletPath(o, Profile(o));
|
var path = WalletPath(o, Profile(o));
|
||||||
if (!WalletStore.Exists(path))
|
if (!WalletStore.Exists(path))
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public enum ScriptKind
|
|||||||
|
|
||||||
/// <summary>P2WSH (multisig native).</summary>
|
/// <summary>P2WSH (multisig native).</summary>
|
||||||
NativeSegwitMultisig,
|
NativeSegwitMultisig,
|
||||||
|
|
||||||
|
/// <summary>P2TR key-path only (Taproot, BIP86) — witness v1, bech32m.</summary>
|
||||||
|
Taproot,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public static class ChainProfiles
|
|||||||
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
||||||
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
||||||
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
||||||
|
// Nessun SLIP-132 per P2TR: il contesto è dato dal path m/86'/…
|
||||||
|
[ScriptKind.Taproot] = new(0x0488ade4, 0x0488b21e), // xprv / xpub (BIP32 standard)
|
||||||
},
|
},
|
||||||
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
||||||
// peer vengono scoperti via server.peers.subscribe.
|
// peer vengono scoperti via server.peers.subscribe.
|
||||||
@@ -70,6 +72,7 @@ public static class ChainProfiles
|
|||||||
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
||||||
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
|
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
|
||||||
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
|
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
|
||||||
|
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
||||||
},
|
},
|
||||||
BootstrapServers = [],
|
BootstrapServers = [],
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ public static class DerivationPaths
|
|||||||
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
|
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
|
||||||
public const int PurposeMultisig = 48;
|
public const int PurposeMultisig = 48;
|
||||||
|
|
||||||
|
/// <summary>Purpose BIP86 (P2TR key-path, Taproot).</summary>
|
||||||
|
public const int PurposeTaproot = 86;
|
||||||
|
|
||||||
public static int PurposeFor(ScriptKind kind) => kind switch
|
public static int PurposeFor(ScriptKind kind) => kind switch
|
||||||
{
|
{
|
||||||
ScriptKind.Legacy => PurposeLegacy,
|
ScriptKind.Legacy => PurposeLegacy,
|
||||||
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
|
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
|
||||||
ScriptKind.NativeSegwit => PurposeNativeSegwit,
|
ScriptKind.NativeSegwit => PurposeNativeSegwit,
|
||||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
|
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
|
||||||
|
ScriptKind.Taproot => PurposeTaproot,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ public static class DerivationPaths
|
|||||||
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
|
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
|
||||||
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
|
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
|
||||||
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
|
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
|
||||||
|
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
|
||||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
|
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace PalladiumWallet.Core.Crypto;
|
|||||||
/// quindi il watch-only funziona per costruzione. Il keystore completo
|
/// quindi il watch-only funziona per costruzione. Il keystore completo
|
||||||
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
|
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HdAccount
|
public sealed class HdAccount : IWalletAccount
|
||||||
{
|
{
|
||||||
private readonly ExtKey? _accountXprv;
|
private readonly ExtKey? _accountXprv;
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ public sealed class HdAccount
|
|||||||
masterFingerprint ?? default);
|
masterFingerprint ?? default);
|
||||||
|
|
||||||
public BitcoinAddress GetAddress(bool isChange, int index) =>
|
public BitcoinAddress GetAddress(bool isChange, int index) =>
|
||||||
GetPublicKey(isChange, index).GetAddress(
|
GetPublicKey(isChange, index)!.GetAddress(
|
||||||
DerivationPaths.ScriptPubKeyTypeFor(Kind),
|
DerivationPaths.ScriptPubKeyTypeFor(Kind),
|
||||||
PalladiumNetworks.For(Profile.Kind));
|
PalladiumNetworks.For(Profile.Kind));
|
||||||
|
|
||||||
@@ -94,9 +94,16 @@ public sealed class HdAccount
|
|||||||
|
|
||||||
public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index);
|
public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index);
|
||||||
|
|
||||||
public PubKey GetPublicKey(bool isChange, int index) =>
|
public PubKey? GetPublicKey(bool isChange, int index) =>
|
||||||
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
||||||
|
|
||||||
|
/// <summary>Chiave privata di un indirizzo; null se watch-only (§17).</summary>
|
||||||
|
public Key? GetPrivateKey(bool isChange, int index) =>
|
||||||
|
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
||||||
|
|
||||||
|
/// <summary>Gli account HD usano il gap limit: nessuna lista fissa di indirizzi.</summary>
|
||||||
|
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
||||||
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Astrazione su tutti i tipi di account wallet (HD da seed, HD da xpub/xprv importata,
|
||||||
|
/// chiavi WIF importate). Consente a WalletSynchronizer e TransactionFactory di operare
|
||||||
|
/// indipendentemente dal tipo di keystore sottostante (blueprint §4.4–§4.5).
|
||||||
|
/// </summary>
|
||||||
|
public interface IWalletAccount
|
||||||
|
{
|
||||||
|
ScriptKind Kind { get; }
|
||||||
|
ChainProfile Profile { get; }
|
||||||
|
|
||||||
|
/// <summary>True se l'account non può firmare (assenza di chiavi private).</summary>
|
||||||
|
bool IsWatchOnly { get; }
|
||||||
|
|
||||||
|
BitcoinAddress GetAddress(bool isChange, int index);
|
||||||
|
BitcoinAddress GetReceiveAddress(int index);
|
||||||
|
BitcoinAddress GetChangeAddress(int index);
|
||||||
|
|
||||||
|
/// <summary>Null se la chiave pubblica non è ricavabile dall'account (indirizzi puri watch-only).</summary>
|
||||||
|
PubKey? GetPublicKey(bool isChange, int index);
|
||||||
|
|
||||||
|
/// <summary>Null se l'account è watch-only o l'indice è fuori range.</summary>
|
||||||
|
Key? GetPrivateKey(bool isChange, int index);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per gli account con indirizzi fissi (WIF importati) restituisce la lista
|
||||||
|
/// completa da scansionare; null per gli account HD che usano il gap limit.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Account da chiavi WIF singole importate (blueprint §4.4 — "Imported"):
|
||||||
|
/// lista fissa di indirizzi, nessuna derivazione HD, nessuna catena di change.
|
||||||
|
/// Il change va sempre al primo indirizzo importato.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImportedKeyAccount : IWalletAccount
|
||||||
|
{
|
||||||
|
private readonly (BitcoinAddress Address, Key? PrivateKey)[] _entries;
|
||||||
|
|
||||||
|
public ScriptKind Kind { get; }
|
||||||
|
public ChainProfile Profile { get; }
|
||||||
|
public bool IsWatchOnly => _entries.All(e => e.PrivateKey is null);
|
||||||
|
|
||||||
|
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses =>
|
||||||
|
_entries.Select((e, i) => (e.Address, false, i)).ToList();
|
||||||
|
|
||||||
|
public ImportedKeyAccount(
|
||||||
|
IReadOnlyList<(BitcoinAddress Address, Key? PrivateKey)> entries,
|
||||||
|
ScriptKind kind, ChainProfile profile)
|
||||||
|
{
|
||||||
|
if (entries.Count == 0)
|
||||||
|
throw new ArgumentException("Almeno un indirizzo richiesto.", nameof(entries));
|
||||||
|
_entries = [.. entries];
|
||||||
|
Kind = kind;
|
||||||
|
Profile = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BitcoinAddress GetAddress(bool isChange, int index)
|
||||||
|
{
|
||||||
|
if (isChange || index < 0 || index >= _entries.Length)
|
||||||
|
return _entries[0].Address;
|
||||||
|
return _entries[index].Address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BitcoinAddress GetReceiveAddress(int index) => GetAddress(false, index);
|
||||||
|
|
||||||
|
/// <summary>Il change torna sempre al primo indirizzo importato.</summary>
|
||||||
|
public BitcoinAddress GetChangeAddress(int index) => _entries[0].Address;
|
||||||
|
|
||||||
|
public PubKey? GetPublicKey(bool isChange, int index)
|
||||||
|
{
|
||||||
|
if (isChange || index < 0 || index >= _entries.Length)
|
||||||
|
return null;
|
||||||
|
return _entries[index].PrivateKey?.PubKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Key? GetPrivateKey(bool isChange, int index)
|
||||||
|
{
|
||||||
|
if (isChange || index < 0 || index >= _entries.Length)
|
||||||
|
return null;
|
||||||
|
return _entries[index].PrivateKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
@@ -35,11 +37,15 @@ public sealed class SyncResult
|
|||||||
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
||||||
/// estende la scansione fino al gap limit (§5).
|
/// estende la scansione fino al gap limit (§5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20)
|
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
||||||
{
|
{
|
||||||
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
||||||
public event Action<string>? Progress;
|
public event Action<string>? Progress;
|
||||||
|
|
||||||
|
// Richieste contemporanee verso il server. Troppo alte → -102 "server busy";
|
||||||
|
// troppo basse → throughput scarso su storie grandi.
|
||||||
|
private const int MaxConcurrent = 20;
|
||||||
|
|
||||||
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
||||||
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
||||||
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
||||||
@@ -47,56 +53,150 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
private readonly Dictionary<string, Transaction> _txCache = [];
|
private readonly Dictionary<string, Transaction> _txCache = [];
|
||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
|
|
||||||
|
// Header grezzi per altezza: una Task<string> per altezza, condivisa tra
|
||||||
|
// tutte le tx dello stesso blocco → ogni blocco viene scaricato una sola
|
||||||
|
// volta anche con centinaia di tx confermate nello stesso blocco.
|
||||||
|
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-popola le cache interne da dati salvati su disco (SyncCache).
|
||||||
|
/// Chiamare prima di SyncOnceAsync per evitare di riscaricale le tx già note.
|
||||||
|
/// </summary>
|
||||||
|
public void PreloadCaches(Dictionary<string, string> rawTxHex,
|
||||||
|
Dictionary<string, int> verifiedAt, Network network)
|
||||||
|
{
|
||||||
|
foreach (var (txid, hex) in rawTxHex)
|
||||||
|
if (!_txCache.ContainsKey(txid))
|
||||||
|
_txCache[txid] = Transaction.Parse(hex, network);
|
||||||
|
foreach (var (txid, height) in verifiedAt)
|
||||||
|
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||||
|
_verifiedAtHeight[txid] = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Esporta le cache correnti in forma serializzabile su disco.
|
||||||
|
/// Solo le tx confermate (height > 0) vengono incluse: le non confermate
|
||||||
|
/// possono cambiare (RBF) e vanno sempre riscaricate.
|
||||||
|
/// </summary>
|
||||||
|
public (Dictionary<string, string> RawTxHex, Dictionary<string, int> VerifiedAt)
|
||||||
|
ExportCaches(Network network)
|
||||||
|
{
|
||||||
|
// Includi solo le tx associate a una prova di Merkle verificata
|
||||||
|
// (cioè confermate e verificate): sono le uniche immutabili.
|
||||||
|
var rawHex = _verifiedAtHeight.Keys
|
||||||
|
.Where(_txCache.ContainsKey)
|
||||||
|
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||||
|
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tip = await client.SubscribeHeadersAsync(ct);
|
var tip = await client.SubscribeHeadersAsync(ct);
|
||||||
Progress?.Invoke($"tip della catena: {tip.Height}");
|
Progress?.Invoke($"tip della catena: {tip.Height}");
|
||||||
|
|
||||||
// 1-2. Scansione indirizzi con gap limit, per catena receiving e change.
|
// 1-2. Scansione indirizzi.
|
||||||
var tracked = new List<TrackedAddress>();
|
var tracked = new List<TrackedAddress>();
|
||||||
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||||
var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
int nextReceive, nextChange;
|
||||||
var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
|
||||||
|
if (account.FixedAddresses is { } fixedAddresses)
|
||||||
|
{
|
||||||
|
// Importati WIF: lista fissa, nessun gap limit.
|
||||||
|
// Pochi indirizzi → subscribe diretto per notifiche push.
|
||||||
|
foreach (var (addr, isChange, idx) in fixedAddresses)
|
||||||
|
tracked.Add(new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, idx));
|
||||||
|
nextReceive = tracked.Count(t => !t.IsChange);
|
||||||
|
nextChange = 0;
|
||||||
|
|
||||||
|
var histories = await Task.WhenAll(
|
||||||
|
tracked.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||||
|
for (var i = 0; i < tracked.Count; i++)
|
||||||
|
{
|
||||||
|
if (histories[i].Count > 0)
|
||||||
|
historyByAddress[tracked[i].ScriptHash] = histories[i];
|
||||||
|
}
|
||||||
|
// Subscribe a tutti (pochi): notifiche push per ogni indirizzo importato.
|
||||||
|
await Task.WhenAll(tracked.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// HD: discovery con GetHistoryAsync (senza subscription → no -101 su wallet grandi);
|
||||||
|
// subscribe solo al gap window per ricevere notifiche push di nuove tx.
|
||||||
|
nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
||||||
|
nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
||||||
|
|
||||||
|
// Iscriviti al gap window (prossimi indirizzi attesi) per notifiche push.
|
||||||
|
// In questo modo il numero di subscription è sempre ≤ 2×gapLimit, indipendentemente
|
||||||
|
// dalla dimensione dello storico — nessun rischio di -101.
|
||||||
|
var gapAddresses = tracked.Where(t =>
|
||||||
|
(!t.IsChange && t.Index >= nextReceive && t.Index < nextReceive + gapLimit) ||
|
||||||
|
( t.IsChange && t.Index >= nextChange && t.Index < nextChange + gapLimit)).ToList();
|
||||||
|
if (gapAddresses.Count > 0)
|
||||||
|
await Task.WhenAll(gapAddresses.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Storico unico (txid → altezza massima riportata).
|
// 3. Storico unico (txid → altezza massima riportata).
|
||||||
var txHeights = new Dictionary<string, int>();
|
var txHeights = new Dictionary<string, int>();
|
||||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||||
txHeights[item.TxHash] = item.Height;
|
txHeights[item.TxHash] = item.Height;
|
||||||
|
|
||||||
// 4. Scarica in parallelo le sole transazioni nuove.
|
// 4. Scarica le transazioni nuove: semaforo MaxConcurrent per non saturare
|
||||||
|
// il server, con aggiornamento progresso in tempo reale.
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
if (missing.Count > 0)
|
if (missing.Count > 0)
|
||||||
{
|
{
|
||||||
Progress?.Invoke($"scarico {missing.Count} transazioni…");
|
var dlSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
||||||
var downloaded = await Task.WhenAll(missing.Select(async txid =>
|
var dlDone = 0;
|
||||||
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
|
Progress?.Invoke($"scarico 0/{missing.Count} transazioni…");
|
||||||
foreach (var (txid, tx) in downloaded)
|
await Task.WhenAll(missing.Select(async txid =>
|
||||||
_txCache[txid] = tx;
|
{
|
||||||
|
await dlSem.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = await client.GetTransactionAsync(txid, ct);
|
||||||
|
_txCache[txid] = Transaction.Parse(raw, network);
|
||||||
|
var n = Interlocked.Increment(ref dlDone);
|
||||||
|
Progress?.Invoke($"scarico {n}/{missing.Count} transazioni…");
|
||||||
|
}
|
||||||
|
finally { dlSem.Release(); }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||||
|
|
||||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
|
// 5. Verifica Merkle delle confermate (§7.4 punto 4).
|
||||||
// solo per le tx non ancora verificate a quell'altezza.
|
// Gli header per altezza sono condivisi via _headerFetches: se 500 tx
|
||||||
|
// stanno nello stesso blocco, l'header viene scaricato una sola volta.
|
||||||
var toVerify = txHeights
|
var toVerify = txHeights
|
||||||
.Where(kv => kv.Value > 0
|
.Where(kv => kv.Value > 0
|
||||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (toVerify.Count > 0)
|
if (toVerify.Count > 0)
|
||||||
{
|
{
|
||||||
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
var merkSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
||||||
|
var merkDone = 0;
|
||||||
|
Progress?.Invoke($"verifico 0/{toVerify.Count} prove di Merkle…");
|
||||||
await Task.WhenAll(toVerify.Select(async kv =>
|
await Task.WhenAll(toVerify.Select(async kv =>
|
||||||
{
|
{
|
||||||
var (txid, height) = kv;
|
await merkSem.WaitAsync(ct);
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
try
|
||||||
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
{
|
||||||
var proof = await proofTask;
|
var (txid, height) = kv;
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
// Proof e header in parallelo; l'header è condiviso per altezza.
|
||||||
if (!MerkleProof.Verify(
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
uint256.Parse(txid), proof.Pos,
|
var headerTask = _headerFetches.GetOrAdd(height,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
h => client.GetBlockHeaderAsync(h, ct));
|
||||||
throw new SpvVerificationException(
|
var proof = await proofTask;
|
||||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
|
if (!MerkleProof.Verify(
|
||||||
|
uint256.Parse(txid), proof.Pos,
|
||||||
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
|
throw new SpvVerificationException(
|
||||||
|
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||||
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
|
Progress?.Invoke($"verifico {n}/{toVerify.Count} prove di Merkle…");
|
||||||
|
}
|
||||||
|
finally { merkSem.Release(); }
|
||||||
}));
|
}));
|
||||||
foreach (var (txid, height) in toVerify)
|
foreach (var (txid, height) in toVerify)
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
@@ -194,8 +294,10 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||||
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit per volta.
|
||||||
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
/// Usa GetHistoryAsync per la discovery — senza subscription → nessun rischio di
|
||||||
|
/// -101 "excessive resource usage" su wallet con molti indirizzi storici.
|
||||||
|
/// Le subscription per notifiche push vengono gestite dal chiamante (solo gap window).
|
||||||
/// Ritorna il primo indice non usato.
|
/// Ritorna il primo indice non usato.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
||||||
@@ -214,19 +316,15 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
index += batch.Count;
|
index += batch.Count;
|
||||||
tracked.AddRange(batch);
|
tracked.AddRange(batch);
|
||||||
|
|
||||||
// La subscribe registra anche la notifica push per i cambi futuri.
|
// GetHistoryAsync per discovery: risposta vuota [] se inutilizzato,
|
||||||
var statuses = await Task.WhenAll(
|
// lista di tx se usato — un solo round-trip per indirizzo.
|
||||||
batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
|
||||||
|
|
||||||
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
|
|
||||||
var histories = await Task.WhenAll(
|
var histories = await Task.WhenAll(
|
||||||
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
batch.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||||
for (var i = 0; i < used.Count; i++)
|
|
||||||
historyByAddress[used[i].ScriptHash] = histories[i];
|
|
||||||
|
|
||||||
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||||
{
|
{
|
||||||
if (statuses[i] is null)
|
var history = histories[i];
|
||||||
|
if (history.Count == 0)
|
||||||
{
|
{
|
||||||
consecutiveEmpty++;
|
consecutiveEmpty++;
|
||||||
}
|
}
|
||||||
@@ -234,6 +332,7 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
{
|
{
|
||||||
consecutiveEmpty = 0;
|
consecutiveEmpty = 0;
|
||||||
firstUnused = batch[i].Index + 1;
|
firstUnused = batch[i].Index + 1;
|
||||||
|
historyByAddress[batch[i].ScriptHash] = history;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,20 @@ public sealed class WalletDocument
|
|||||||
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
||||||
public string? Passphrase { get; set; }
|
public string? Passphrase { get; set; }
|
||||||
|
|
||||||
/// <summary>Path di account (es. "84'/746'/0'").</summary>
|
/// <summary>Path di account (es. "84'/746'/0'"); null per importati WIF.</summary>
|
||||||
public required string AccountPath { get; set; }
|
public string? AccountPath { get; set; }
|
||||||
|
|
||||||
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
|
/// <summary>Xpub di account in SLIP-132 per i wallet HD; null per importati WIF.</summary>
|
||||||
public required string AccountXpub { get; set; }
|
public string? AccountXpub { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Xprv di account in SLIP-132; presente solo per import da xprv senza seed.</summary>
|
||||||
|
public string? AccountXprv { get; set; }
|
||||||
|
|
||||||
public string? MasterFingerprint { get; set; }
|
public string? MasterFingerprint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Chiavi WIF importate (in chiaro nel documento — va cifrato!).</summary>
|
||||||
|
public List<string>? WifKeys { get; set; }
|
||||||
|
|
||||||
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
||||||
public int GapLimit { get; set; } = 20;
|
public int GapLimit { get; set; } = 20;
|
||||||
|
|
||||||
@@ -46,7 +52,10 @@ public sealed class WalletDocument
|
|||||||
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
||||||
public SyncCache? Cache { get; set; }
|
public SyncCache? Cache { get; set; }
|
||||||
|
|
||||||
public bool IsWatchOnly => Mnemonic is null;
|
public bool IsWatchOnly =>
|
||||||
|
Mnemonic is null &&
|
||||||
|
AccountXprv is null &&
|
||||||
|
(WifKeys is null || WifKeys.Count == 0);
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -90,6 +99,20 @@ public sealed class SyncCache
|
|||||||
public List<CachedTx> History { get; set; } = [];
|
public List<CachedTx> History { get; set; } = [];
|
||||||
public List<CachedUtxo> Utxos { get; set; } = [];
|
public List<CachedUtxo> Utxos { get; set; } = [];
|
||||||
public List<CachedAddress> Addresses { get; set; } = [];
|
public List<CachedAddress> Addresses { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cache raw delle transazioni confermate (txid → hex). Evita di riscaricale
|
||||||
|
/// ad ogni avvio dell'app: le tx confermate sono immutabili per definizione.
|
||||||
|
/// Popolata anche parzialmente in caso di sync interrotta (es. -101):
|
||||||
|
/// il synchronizer riprende dal punto in cui si era fermato.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string>? RawTxHex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prove di Merkle già verificate (txid → altezza blocco). Evita di
|
||||||
|
/// riverificare le stesse prove ad ogni avvio: le conferme sono immutabili.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, int>? VerifiedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class BuiltTransaction
|
|||||||
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
|
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
|
||||||
/// Con un account watch-only produce la PSBT non firmata (§6.5).
|
/// Con un account watch-only produce la PSBT non firmata (§6.5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransactionFactory(HdAccount account)
|
public sealed class TransactionFactory(IWalletAccount account)
|
||||||
{
|
{
|
||||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||||
|
|
||||||
@@ -82,7 +82,8 @@ public sealed class TransactionFactory(HdAccount account)
|
|||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
builder.AddKeys(spendable
|
builder.AddKeys(spendable
|
||||||
.Select(u => account.GetExtPrivateKey(u.IsChange, u.AddressIndex))
|
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
||||||
|
.OfType<Key>()
|
||||||
.ToArray());
|
.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,31 +6,58 @@ using PalladiumWallet.Core.Storage;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce
|
/// Factory dei tipi di wallet (blueprint §4.5): dal documento ricostruisce il tipo
|
||||||
/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della
|
/// di account corretto (HD da seed, HD da xprv importata, importato WIF, watch-only).
|
||||||
/// factory dei tipi di wallet; crescerà con multisig e importati.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WalletLoader
|
public static class WalletLoader
|
||||||
{
|
{
|
||||||
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
||||||
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
||||||
|
|
||||||
public static HdAccount ToAccount(WalletDocument doc)
|
public static IWalletAccount ToAccount(WalletDocument doc)
|
||||||
{
|
{
|
||||||
var profile = ProfileOf(doc);
|
var profile = ProfileOf(doc);
|
||||||
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
||||||
var path = KeyPath.Parse(doc.AccountPath);
|
var network = PalladiumNetworks.For(profile.Kind);
|
||||||
|
|
||||||
|
// 1. HD da seed (caso più comune)
|
||||||
if (doc.Mnemonic is { } words)
|
if (doc.Mnemonic is { } words)
|
||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out var mnemonic))
|
if (!Bip39.TryParse(words, out var mnemonic))
|
||||||
throw new InvalidDataException("Mnemonica del file wallet non valida.");
|
throw new InvalidDataException("Mnemonica del file wallet non valida.");
|
||||||
|
var path = KeyPath.Parse(doc.AccountPath ?? DerivationPaths.AccountPath(kind, profile).ToString());
|
||||||
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. HD da xprv importata (spendibile, senza seed)
|
||||||
|
if (doc.AccountXprv is { } xprvStr)
|
||||||
|
{
|
||||||
|
if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _))
|
||||||
|
throw new InvalidDataException("Xprv del file wallet non valida per questa rete.");
|
||||||
|
var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null;
|
||||||
|
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Chiavi WIF importate
|
||||||
|
if (doc.WifKeys is { Count: > 0 } wifKeys)
|
||||||
|
{
|
||||||
|
var entries = wifKeys.Select(wif =>
|
||||||
|
{
|
||||||
|
var secret = new BitcoinSecret(wif, network);
|
||||||
|
var addr = secret.PrivateKey.PubKey
|
||||||
|
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
||||||
|
return (addr, (Key?)secret.PrivateKey);
|
||||||
|
}).ToList();
|
||||||
|
return new ImportedKeyAccount(entries, kind, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Watch-only da xpub
|
||||||
|
if (doc.AccountXpub is null)
|
||||||
|
throw new InvalidDataException("File wallet senza xpub e senza seed.");
|
||||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||||
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
|
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
|
||||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, path);
|
var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null;
|
||||||
|
return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
||||||
@@ -56,4 +83,91 @@ public static class WalletLoader
|
|||||||
};
|
};
|
||||||
return (doc, account);
|
return (doc, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crea il documento da una xpub SLIP-132 importata (watch-only).
|
||||||
|
/// Rileva il ScriptKind dagli header SLIP-132; <paramref name="kindOverride"/> lo sovrascrive
|
||||||
|
/// per i prefissi ambigui (xpub può essere Legacy o Taproot).
|
||||||
|
/// </summary>
|
||||||
|
public static (WalletDocument Doc, HdAccount Account) NewFromXpub(
|
||||||
|
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||||
|
{
|
||||||
|
if (!Slip132.TryDecodePublic(slip132.Trim(), profile, out var xpub, out var detectedKind))
|
||||||
|
throw new InvalidDataException("Chiave pubblica estesa non valida o non riconosciuta per questa rete.");
|
||||||
|
var kind = kindOverride ?? detectedKind;
|
||||||
|
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
||||||
|
|
||||||
|
var doc = new WalletDocument
|
||||||
|
{
|
||||||
|
Network = profile.NetName,
|
||||||
|
ScriptKind = kind.ToString(),
|
||||||
|
AccountPath = account.AccountPath.ToString(),
|
||||||
|
AccountXpub = slip132.Trim(),
|
||||||
|
};
|
||||||
|
return (doc, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crea il documento da una xprv SLIP-132 importata (spendibile senza seed).
|
||||||
|
/// Il documento contiene la xprv in chiaro: deve essere obbligatoriamente cifrato.
|
||||||
|
/// </summary>
|
||||||
|
public static (WalletDocument Doc, HdAccount Account) NewFromXprv(
|
||||||
|
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||||
|
{
|
||||||
|
if (!Slip132.TryDecodePrivate(slip132.Trim(), profile, out var xprv, out var detectedKind))
|
||||||
|
throw new InvalidDataException("Chiave privata estesa non valida o non riconosciuta per questa rete.");
|
||||||
|
var kind = kindOverride ?? detectedKind;
|
||||||
|
var account = HdAccount.FromAccountXprv(xprv!, kind, profile);
|
||||||
|
|
||||||
|
var doc = new WalletDocument
|
||||||
|
{
|
||||||
|
Network = profile.NetName,
|
||||||
|
ScriptKind = kind.ToString(),
|
||||||
|
AccountPath = account.AccountPath.ToString(),
|
||||||
|
AccountXpub = account.ToSlip132(),
|
||||||
|
AccountXprv = slip132.Trim(),
|
||||||
|
};
|
||||||
|
return (doc, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crea il documento da una o più chiavi WIF importate.
|
||||||
|
/// Il documento contiene le chiavi WIF in chiaro: deve essere obbligatoriamente cifrato.
|
||||||
|
/// </summary>
|
||||||
|
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
||||||
|
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
|
||||||
|
{
|
||||||
|
if (wifKeys.Count == 0)
|
||||||
|
throw new InvalidDataException("Almeno una chiave WIF richiesta.");
|
||||||
|
|
||||||
|
var network = PalladiumNetworks.For(profile.Kind);
|
||||||
|
var entries = new List<(BitcoinAddress, Key?)>();
|
||||||
|
var wifStrings = new List<string>();
|
||||||
|
|
||||||
|
foreach (var raw in wifKeys)
|
||||||
|
{
|
||||||
|
BitcoinSecret secret;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
secret = new BitcoinSecret(raw.Trim(), network);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Chiave WIF non valida: {ex.Message}");
|
||||||
|
}
|
||||||
|
var addr = secret.PrivateKey.PubKey
|
||||||
|
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
||||||
|
entries.Add((addr, secret.PrivateKey));
|
||||||
|
wifStrings.Add(raw.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = new ImportedKeyAccount(entries, kind, profile);
|
||||||
|
var doc = new WalletDocument
|
||||||
|
{
|
||||||
|
Network = profile.NetName,
|
||||||
|
ScriptKind = kind.ToString(),
|
||||||
|
WifKeys = wifStrings,
|
||||||
|
};
|
||||||
|
return (doc, account);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vettori di test BIP86 (mnemonica abandon-about, senza passphrase).
|
||||||
|
/// La chiave pubblica tweakizzata (output key, 32 byte x-only) è chain-independent:
|
||||||
|
/// viene verificata contro i vettori ufficiali Bitcoin, poi si controlla che
|
||||||
|
/// l'indirizzo PLM abbia il prefisso plm1p (witness v1, bech32m).
|
||||||
|
/// Il path m/86'/0'/0' usa coin_type=0 (non 746) per aderire ai vettori BIP86.
|
||||||
|
/// </summary>
|
||||||
|
public class Bip86TaprootTests
|
||||||
|
{
|
||||||
|
private static HdAccount Account()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
out var mnemonic));
|
||||||
|
return HdAccount.FromSeed(
|
||||||
|
Bip39.ToSeed(mnemonic!),
|
||||||
|
ScriptKind.Taproot,
|
||||||
|
ChainProfiles.Mainnet,
|
||||||
|
new KeyPath("86'/0'/0'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_derivation_path_di_un_account_taproot_usa_purpose_86()
|
||||||
|
{
|
||||||
|
var path = DerivationPaths.AccountPath(ScriptKind.Taproot, ChainProfiles.Mainnet, 0);
|
||||||
|
Assert.Equal($"86'/{ChainProfiles.Mainnet.Bip44CoinType}'/0'", path.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_indirizzi_plm_taproot_iniziano_con_plm1p()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var addr0 = account.GetReceiveAddress(0).ToString();
|
||||||
|
var addr1 = account.GetReceiveAddress(1).ToString();
|
||||||
|
var change0 = account.GetChangeAddress(0).ToString();
|
||||||
|
|
||||||
|
Assert.StartsWith("plm1p", addr0);
|
||||||
|
Assert.StartsWith("plm1p", addr1);
|
||||||
|
Assert.StartsWith("plm1p", change0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L'output key (chiave tweakizzata x-only, 32 byte) è identica al vettore BIP86
|
||||||
|
/// indipendentemente dalla rete: la rete cambia solo HRP e checksum, non il programma.
|
||||||
|
/// Indirizzi Bitcoin da https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr")]
|
||||||
|
[InlineData(false, 1, "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh")]
|
||||||
|
[InlineData(true, 0, "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7")]
|
||||||
|
public void L_output_key_coincide_col_vettore_bip86(
|
||||||
|
bool isChange, int index, string bitcoinAddress)
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var plmAddr = account.GetAddress(isChange, index);
|
||||||
|
|
||||||
|
// Il witness program (output key tweakizzata) è chain-independent
|
||||||
|
var btcAddr = (TaprootAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
||||||
|
var plmTaproot = (TaprootAddress)plmAddr;
|
||||||
|
Assert.Equal(btcAddr.PubKey, plmTaproot.PubKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_wallet_taproot_e_watch_only_se_creato_da_xpub()
|
||||||
|
{
|
||||||
|
var full = Account();
|
||||||
|
var watchOnly = HdAccount.FromAccountXpub(
|
||||||
|
full.AccountXpub, ScriptKind.Taproot, ChainProfiles.Mainnet);
|
||||||
|
Assert.True(watchOnly.IsWatchOnly);
|
||||||
|
Assert.Equal(
|
||||||
|
full.GetReceiveAddress(0).ToString(),
|
||||||
|
watchOnly.GetReceiveAddress(0).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void I_tipi_multisig_lanciano_not_supported()
|
||||||
|
{
|
||||||
|
Assert.Throws<NotSupportedException>(
|
||||||
|
() => DerivationPaths.ScriptPubKeyTypeFor(ScriptKind.WrappedSegwitMultisig));
|
||||||
|
Assert.Throws<NotSupportedException>(
|
||||||
|
() => DerivationPaths.ScriptPubKeyTypeFor(ScriptKind.NativeSegwitMultisig));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Wallet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test per ImportedKeyAccount e i nuovi percorsi factory in WalletLoader
|
||||||
|
/// (blueprint §4.4 — importati WIF, xpub, xprv).
|
||||||
|
/// </summary>
|
||||||
|
public class ImportedKeyAccountTests
|
||||||
|
{
|
||||||
|
private static ChainProfile Profile => ChainProfiles.Mainnet;
|
||||||
|
private static Network Network => PalladiumNetworks.For(Profile.Kind);
|
||||||
|
|
||||||
|
// Chiave WIF valida per PLM mainnet (prefix 0x80 = Compressed WIF "K"/"L")
|
||||||
|
private static Key GenerateKey() => new Key();
|
||||||
|
|
||||||
|
private static string ToWif(Key key) => key.GetWif(Network).ToString();
|
||||||
|
|
||||||
|
// ---- ImportedKeyAccount ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Account_importato_non_e_hd()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount(
|
||||||
|
[(addr, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.False(account.IsWatchOnly);
|
||||||
|
Assert.Equal(ScriptKind.NativeSegwit, account.Kind);
|
||||||
|
Assert.NotNull(account.FixedAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAddress_restituisce_indirizzo_corretto()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.Equal(addr.ToString(), account.GetAddress(false, 0).ToString());
|
||||||
|
Assert.Equal(addr.ToString(), account.GetReceiveAddress(0).ToString());
|
||||||
|
// Change → primo indirizzo
|
||||||
|
Assert.Equal(addr.ToString(), account.GetChangeAddress(0).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPrivateKey_restituisce_chiave_corretta()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var retrieved = account.GetPrivateKey(false, 0);
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.Equal(key.ToBytes(), retrieved!.ToBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPrivateKey_fuori_range_restituisce_null()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.Null(account.GetPrivateKey(false, 99));
|
||||||
|
Assert.Null(account.GetPrivateKey(true, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Account_watch_only_se_nessuna_chiave_privata()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount(
|
||||||
|
[(addr, (Key?)null)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.True(account.IsWatchOnly);
|
||||||
|
Assert.Null(account.GetPrivateKey(false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedAddresses_copre_tutti_gli_indirizzi()
|
||||||
|
{
|
||||||
|
var keys = Enumerable.Range(0, 3).Select(_ => GenerateKey()).ToList();
|
||||||
|
var entries = keys
|
||||||
|
.Select(k => (k.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network), (Key?)k))
|
||||||
|
.ToList();
|
||||||
|
var account = new ImportedKeyAccount(entries, ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var fixed_ = account.FixedAddresses!;
|
||||||
|
Assert.Equal(3, fixed_.Count);
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(entries[i].Item1.ToString(), fixed_[i].Address.ToString());
|
||||||
|
Assert.False(fixed_[i].IsChange);
|
||||||
|
Assert.Equal(i, fixed_[i].Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WalletLoader.NewFromWif ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromWif_crea_account_con_indirizzi_corretti()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var wif = ToWif(key);
|
||||||
|
var (doc, account) = WalletLoader.NewFromWif([wif], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.Null(doc.Mnemonic);
|
||||||
|
Assert.NotNull(doc.WifKeys);
|
||||||
|
Assert.Single(doc.WifKeys!);
|
||||||
|
Assert.False(doc.IsWatchOnly);
|
||||||
|
Assert.Equal(ScriptKind.NativeSegwit.ToString(), doc.ScriptKind);
|
||||||
|
|
||||||
|
var expected = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network).ToString();
|
||||||
|
Assert.Equal(expected, account.GetReceiveAddress(0).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromWif_roundtrip_persiste_e_ricarica()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var wif = ToWif(key);
|
||||||
|
var (doc, original) = WalletLoader.NewFromWif([wif], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var reloaded = WalletLoader.ToAccount(doc);
|
||||||
|
Assert.IsType<ImportedKeyAccount>(reloaded);
|
||||||
|
Assert.Equal(
|
||||||
|
original.GetReceiveAddress(0).ToString(),
|
||||||
|
reloaded.GetReceiveAddress(0).ToString());
|
||||||
|
Assert.False(reloaded.IsWatchOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromWif_wif_invalido_lancia_eccezione()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidDataException>(
|
||||||
|
() => WalletLoader.NewFromWif(["not-a-wif"], ScriptKind.NativeSegwit, Profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WalletLoader.NewFromXpub ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromXpub_produce_account_watch_only()
|
||||||
|
{
|
||||||
|
// Crea un HD account, esporta la zpub, reimporta come xpub watch-only.
|
||||||
|
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
null, ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var zpub = hdFull.ToSlip132();
|
||||||
|
var (doc, woAccount) = WalletLoader.NewFromXpub(zpub, Profile);
|
||||||
|
|
||||||
|
Assert.True(woAccount.IsWatchOnly);
|
||||||
|
Assert.Null(doc.Mnemonic);
|
||||||
|
Assert.Equal(
|
||||||
|
hdFull.GetReceiveAddress(0).ToString(),
|
||||||
|
woAccount.GetReceiveAddress(0).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WalletLoader.NewFromXprv ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromXprv_produce_account_spendibile()
|
||||||
|
{
|
||||||
|
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
null, ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var zprv = hdFull.ToSlip132Private();
|
||||||
|
var (doc, xprvAccount) = WalletLoader.NewFromXprv(zprv, Profile);
|
||||||
|
|
||||||
|
Assert.False(xprvAccount.IsWatchOnly);
|
||||||
|
Assert.NotNull(doc.AccountXprv);
|
||||||
|
Assert.Null(doc.Mnemonic);
|
||||||
|
Assert.Equal(
|
||||||
|
hdFull.GetReceiveAddress(0).ToString(),
|
||||||
|
xprvAccount.GetReceiveAddress(0).ToString());
|
||||||
|
Assert.NotNull(xprvAccount.GetPrivateKey(false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromXprv_roundtrip_persiste_e_ricarica()
|
||||||
|
{
|
||||||
|
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
null, ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var zprv = hdFull.ToSlip132Private();
|
||||||
|
var (doc, _) = WalletLoader.NewFromXprv(zprv, Profile);
|
||||||
|
|
||||||
|
var reloaded = WalletLoader.ToAccount(doc);
|
||||||
|
Assert.IsType<HdAccount>(reloaded);
|
||||||
|
Assert.False(reloaded.IsWatchOnly);
|
||||||
|
Assert.Equal(
|
||||||
|
hdFull.GetReceiveAddress(0).ToString(),
|
||||||
|
reloaded.GetReceiveAddress(0).ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,7 +153,8 @@ public class WalletLoaderTests
|
|||||||
AccountXpub = doc.AccountXpub,
|
AccountXpub = doc.AccountXpub,
|
||||||
};
|
};
|
||||||
var account = WalletLoader.ToAccount(docWo);
|
var account = WalletLoader.ToAccount(docWo);
|
||||||
Assert.Throws<InvalidOperationException>(() => account.GetExtPrivateKey(false, 0));
|
Assert.True(account.IsWatchOnly);
|
||||||
|
Assert.Null(account.GetPrivateKey(false, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user