diff --git a/src/Core/Storage/AppPaths.cs b/src/Core/Storage/AppPaths.cs
new file mode 100644
index 0000000..3117e14
--- /dev/null
+++ b/src/Core/Storage/AppPaths.cs
@@ -0,0 +1,49 @@
+using PalladiumWallet.Core.Chain;
+
+namespace PalladiumWallet.Core.Storage;
+
+///
+/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
+/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
+/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
+/// esiste una cartella "palladium-data".
+///
+public static class AppPaths
+{
+ public const string PortableDirName = "palladium-data";
+
+ public static string DataRoot()
+ {
+ var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
+ if (Directory.Exists(portable))
+ return portable;
+
+ return OperatingSystem.IsWindows()
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet")
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
+ }
+
+ /// Cartella dati della rete (config, wallet, header, certificati).
+ public static string ForNetwork(NetKind net)
+ {
+ var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public static string WalletsDir(NetKind net)
+ {
+ var dir = Path.Combine(ForNetwork(net), "wallets");
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public static string DefaultWalletPath(NetKind net) =>
+ Path.Combine(WalletsDir(net), "default.wallet.json");
+
+ public static string CertificatePinsPath(NetKind net) =>
+ Path.Combine(ForNetwork(net), "server-certs.json");
+
+ public static string ConfigPath() =>
+ Path.Combine(DataRoot(), "config.json");
+}
diff --git a/src/Core/Storage/EncryptedFile.cs b/src/Core/Storage/EncryptedFile.cs
new file mode 100644
index 0000000..47f665a
--- /dev/null
+++ b/src/Core/Storage/EncryptedFile.cs
@@ -0,0 +1,95 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace PalladiumWallet.Core.Storage;
+
+///
+/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
+/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
+/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
+///
+public static class EncryptedFile
+{
+ private const string Format = "plm-wallet-aesgcm-v1";
+ private const int DefaultIterations = 600_000;
+ private const int SaltSize = 16;
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+ private const int KeySize = 32;
+
+ private sealed record Container(
+ string Format, int Iterations, string Salt, string Nonce, string Tag, string Data);
+
+ public static bool IsEncrypted(string fileContent)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(fileContent);
+ return doc.RootElement.TryGetProperty("Format", out var f)
+ && f.GetString() == Format;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ public static string Encrypt(string plaintext, string password)
+ {
+ var salt = RandomNumberGenerator.GetBytes(SaltSize);
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var key = DeriveKey(password, salt, DefaultIterations);
+
+ var plainBytes = Encoding.UTF8.GetBytes(plaintext);
+ var cipher = new byte[plainBytes.Length];
+ var tag = new byte[TagSize];
+ using (var aes = new AesGcm(key, TagSize))
+ aes.Encrypt(nonce, plainBytes, cipher, tag);
+ CryptographicOperations.ZeroMemory(key);
+
+ return JsonSerializer.Serialize(new Container(
+ Format, DefaultIterations,
+ Convert.ToBase64String(salt), Convert.ToBase64String(nonce),
+ Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
+ }
+
+ /// Lancia se la password è errata o il file è manomesso.
+ public static string Decrypt(string fileContent, string password)
+ {
+ var container = JsonSerializer.Deserialize(fileContent)
+ ?? throw new InvalidDataException("Contenitore cifrato non valido.");
+ if (container.Format != Format)
+ throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
+
+ var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
+ var cipher = Convert.FromBase64String(container.Data);
+ var plain = new byte[cipher.Length];
+ try
+ {
+ using var aes = new AesGcm(key, TagSize);
+ aes.Decrypt(
+ Convert.FromBase64String(container.Nonce), cipher,
+ Convert.FromBase64String(container.Tag), plain);
+ }
+ catch (AuthenticationTagMismatchException)
+ {
+ // Il tag GCM autentica: password errata e manomissione sono indistinguibili.
+ throw new WrongPasswordException();
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(key);
+ }
+ return Encoding.UTF8.GetString(plain);
+ }
+
+ private static byte[] DeriveKey(string password, byte[] salt, int iterations) =>
+ Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
+}
+
+/// Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).
+public sealed class WrongPasswordException : Exception
+{
+ public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
+}
diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs
new file mode 100644
index 0000000..2139f9a
--- /dev/null
+++ b/src/Core/Storage/WalletDocument.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PalladiumWallet.Core.Storage;
+
+///
+/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
+/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
+/// l'intero documento via .
+///
+public sealed class WalletDocument
+{
+ public const int CurrentVersion = 1;
+
+ public int Version { get; set; } = CurrentVersion;
+
+ /// Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.
+ public required string Network { get; set; }
+
+ /// Tipo di script (nome dell'enum ScriptKind).
+ public required string ScriptKind { get; set; }
+
+ /// Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.
+ public string? Mnemonic { get; set; }
+
+ /// Extension word BIP39 (§4.1); null se assente.
+ public string? Passphrase { get; set; }
+
+ /// Path di account (es. "84'/746'/0'").
+ public required string AccountPath { get; set; }
+
+ /// Xpub di account in SLIP-132: basta da sola per il watch-only.
+ public required string AccountXpub { get; set; }
+
+ public string? MasterFingerprint { get; set; }
+
+ /// Gap limit per la scansione indirizzi (§5), configurabile.
+ public int GapLimit { get; set; } = 20;
+
+ /// Etichette per indirizzo/txid (§12).
+ public Dictionary Labels { get; set; } = [];
+
+ /// Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).
+ public SyncCache? Cache { get; set; }
+
+ public bool IsWatchOnly => Mnemonic is null;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
+
+ public static WalletDocument FromJson(string json)
+ {
+ var doc = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidDataException("File wallet non valido.");
+ return Migrate(doc);
+ }
+
+ /// Migrazioni di schema all'apertura (§8). Per ora esiste solo la v1.
+ private static WalletDocument Migrate(WalletDocument doc) => doc.Version switch
+ {
+ CurrentVersion => doc,
+ _ => throw new InvalidDataException(
+ $"Versione file wallet {doc.Version} non supportata (max {CurrentVersion})."),
+ };
+}
+
+/// Stato sincronizzato persistito: permette di mostrare saldo/storico offline.
+public sealed class SyncCache
+{
+ public int TipHeight { get; set; }
+ public long ConfirmedSats { get; set; }
+ public long UnconfirmedSats { get; set; }
+ public int NextReceiveIndex { get; set; }
+ public int NextChangeIndex { get; set; }
+ public List History { get; set; } = [];
+ public List Utxos { get; set; } = [];
+}
+
+public sealed class CachedTx
+{
+ public required string Txid { get; set; }
+ public int Height { get; set; }
+ public long DeltaSats { get; set; }
+ public bool Verified { get; set; }
+}
+
+public sealed class CachedUtxo
+{
+ public required string Txid { get; set; }
+ public int Vout { get; set; }
+ public long ValueSats { get; set; }
+ public required string Address { get; set; }
+ public bool IsChange { get; set; }
+ public int AddressIndex { get; set; }
+ public int Height { get; set; }
+ public bool Frozen { get; set; }
+}
diff --git a/src/Core/Storage/WalletStore.cs b/src/Core/Storage/WalletStore.cs
new file mode 100644
index 0000000..1264e1b
--- /dev/null
+++ b/src/Core/Storage/WalletStore.cs
@@ -0,0 +1,38 @@
+namespace PalladiumWallet.Core.Storage;
+
+///
+/// Lettura/scrittura del file wallet su disco (blueprint §8): JSON in chiaro
+/// senza password, contenitore AES-GCM con password. Scrittura atomica
+/// (file temporaneo + rename) per non corrompere il wallet su crash.
+///
+public static class WalletStore
+{
+ public static bool Exists(string path) => File.Exists(path);
+
+ /// True se il file richiede una password per l'apertura.
+ public static bool RequiresPassword(string path) =>
+ EncryptedFile.IsEncrypted(File.ReadAllText(path));
+
+ public static WalletDocument Load(string path, string? password = null)
+ {
+ var content = File.ReadAllText(path);
+ if (EncryptedFile.IsEncrypted(content))
+ {
+ if (string.IsNullOrEmpty(password))
+ throw new WrongPasswordException();
+ content = EncryptedFile.Decrypt(content, password);
+ }
+ return WalletDocument.FromJson(content);
+ }
+
+ public static void Save(WalletDocument doc, string path, string? password = null)
+ {
+ var content = doc.ToJson();
+ if (!string.IsNullOrEmpty(password))
+ content = EncryptedFile.Encrypt(content, password);
+
+ var tmp = path + ".tmp";
+ File.WriteAllText(tmp, content);
+ File.Move(tmp, path, overwrite: true);
+ }
+}