diff --git a/src/Core/Crypto/DerivationPaths.cs b/src/Core/Crypto/DerivationPaths.cs index 55d6569..6126032 100644 --- a/src/Core/Crypto/DerivationPaths.cs +++ b/src/Core/Crypto/DerivationPaths.cs @@ -52,6 +52,19 @@ public static class DerivationPaths _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; + /// + /// Best-effort reverse mapping from an already-known address to a ScriptKind, used to + /// label pure address imports (no derivation involved, so this is informational only). + /// + public static ScriptKind KindFor(BitcoinAddress address) => address switch + { + BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit, + TaprootAddress => ScriptKind.Taproot, + BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig, + BitcoinScriptAddress => ScriptKind.WrappedSegwit, + _ => ScriptKind.Legacy, + }; + /// /// Account path relative to the root: purpose'/coin'/account' (§4.2). /// coin_type is taken from the profile (746 mainnet, 1 testnet). diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index a9d8d78..9eddc85 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -40,6 +40,9 @@ public sealed class WalletDocument /// Imported WIF keys (in plaintext in the document — must be encrypted!). public List? WifKeys { get; set; } + /// Watch-only addresses with no associated private key (pure address import). + public List? WatchAddresses { get; set; } + /// Gap limit for address scanning (§5), configurable. public int GapLimit { get; set; } = 20; diff --git a/src/Core/Wallet/WalletLoader.cs b/src/Core/Wallet/WalletLoader.cs index 5b0690b..e8dc25a 100644 --- a/src/Core/Wallet/WalletLoader.cs +++ b/src/Core/Wallet/WalletLoader.cs @@ -51,7 +51,15 @@ public static class WalletLoader return new ImportedKeyAccount(entries, kind, profile); } - // 4. Watch-only from xpub + // 4. Watch-only imported addresses (no keys, no HD derivation) + if (doc.WatchAddresses is { Count: > 0 } watchAddresses) + { + var entries = watchAddresses.Select(a => + (BitcoinAddress.Create(a.Trim(), network), (Key?)null)).ToList(); + return new ImportedKeyAccount(entries, kind, profile); + } + + // 5. Watch-only from xpub if (doc.AccountXpub is null) throw new InvalidDataException("Wallet file has no xpub and no seed."); if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _)) @@ -170,4 +178,47 @@ public static class WalletLoader }; return (doc, account); } + + /// + /// Creates the document from one or more plain addresses, with no private key at all + /// (pure watch-only import — the account can never sign, unlike xpub- or WIF-based accounts + /// which can be upgraded later by supplying the matching key). ScriptKind is informational + /// only here (no derivation happens from a fixed address list) and is auto-detected from the + /// first address unless is given. + /// + public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses( + IReadOnlyList addresses, ChainProfile profile, ScriptKind? kindOverride = null) + { + if (addresses.Count == 0) + throw new InvalidDataException("At least one address is required."); + + var network = PalladiumNetworks.For(profile.Kind); + var entries = new List<(BitcoinAddress, Key?)>(); + var addressStrings = new List(); + + foreach (var raw in addresses) + { + BitcoinAddress addr; + try + { + addr = BitcoinAddress.Create(raw.Trim(), network); + } + catch (Exception ex) + { + throw new InvalidDataException($"Invalid address: {ex.Message}"); + } + entries.Add((addr, null)); + addressStrings.Add(raw.Trim()); + } + + var kind = kindOverride ?? DerivationPaths.KindFor(entries[0].Item1); + var account = new ImportedKeyAccount(entries, kind, profile); + var doc = new WalletDocument + { + Network = profile.NetName, + ScriptKind = kind.ToString(), + WatchAddresses = addressStrings, + }; + return (doc, account); + } } diff --git a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs index fe44106..0520c81 100644 --- a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs +++ b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs @@ -148,6 +148,21 @@ public class StorageTests Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly); } + [Fact] + public void WatchAddresses_fa_roundtrip_json_ed_e_watch_only() + { + var doc = new WalletDocument + { + Network = "mainnet", + ScriptKind = "NativeSegwit", + WatchAddresses = ["bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"], + }; + var restored = WalletDocument.FromJson(doc.ToJson()); + + Assert.Equal(doc.WatchAddresses, restored.WatchAddresses); + Assert.True(restored.IsWatchOnly); + } + [Fact] public void Json_corrotto_lancia_eccezione() { diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs index fa13471..3cc91ce 100644 --- a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs @@ -250,6 +250,33 @@ public class TransactionFactoryTests Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000); } + [Fact] + public void Un_account_di_soli_indirizzi_watch_only_produce_una_psbt_non_firmata() + { + var full = Account(); + var (utxos, txs) = Fund(full, 1_000_000); + + // Pure address import: no private key at all (unlike xpub watch-only, which + // can still derive public keys/scriptPubKeys — this account can't upgrade + // to spendable without the user separately importing the matching key). + var watchOnly = new ImportedKeyAccount( + [(full.GetReceiveAddress(0), (Key?)null)], ScriptKind.NativeSegwit, Profile); + + var built = new TransactionFactory(watchOnly).Build( + utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100); + + Assert.False(built.Signed); + Assert.True(watchOnly.IsWatchOnly); + Assert.Null(watchOnly.GetPrivateKey(false, 0)); + + var psbt = built.Psbt; + psbt.SignWithKeys(full.GetExtPrivateKey(false, 0)); + psbt.Finalize(); + var tx = psbt.ExtractTransaction(); + Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000); + } + [Theory] [InlineData("0.00000001", 1L)] [InlineData("1", 100_000_000L)] diff --git a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs index 094e933..92846dd 100644 --- a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Storage; @@ -249,4 +250,85 @@ public class WalletLoaderTests Assert.Throws( () => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet)); } + + // ---- NewFromAddresses (pure watch-only, no key at all) ---- + + private static string SampleAddress(ScriptKind kind = ScriptKind.NativeSegwit) => + WalletLoader.NewFromMnemonic(ValidMnemonic, null, kind, ChainProfiles.Mainnet) + .Account.GetReceiveAddress(0).ToString(); + + [Fact] + public void NewFromAddresses_crea_documento_watch_only_senza_chiavi() + { + var address = SampleAddress(); + var (doc, account) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet); + + Assert.Equal("mainnet", doc.Network); + Assert.Null(doc.Mnemonic); + Assert.Null(doc.AccountXprv); + Assert.Null(doc.AccountXpub); + Assert.Null(doc.WifKeys); + Assert.Equal([address], doc.WatchAddresses); + Assert.True(doc.IsWatchOnly); + Assert.True(account.IsWatchOnly); + Assert.Null(account.GetPrivateKey(false, 0)); + Assert.Equal(address, account.GetReceiveAddress(0).ToString()); + } + + [Fact] + public void NewFromAddresses_rileva_lo_scriptkind_dallindirizzo() + { + var legacyAddr = SampleAddress(ScriptKind.Legacy); + var (doc, _) = WalletLoader.NewFromAddresses([legacyAddr], ChainProfiles.Mainnet); + Assert.Equal("Legacy", doc.ScriptKind); + } + + [Fact] + public void NewFromAddresses_piu_indirizzi_sono_tutti_scansionabili() + { + var addr1 = SampleAddress(); + var addr2 = WalletLoader.NewFromMnemonic(ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet) + .Account.GetReceiveAddress(0).ToString(); + var (_, account) = WalletLoader.NewFromAddresses([addr1, addr2], ChainProfiles.Mainnet); + + Assert.NotNull(account.FixedAddresses); + Assert.Equal(2, account.FixedAddresses!.Count); + Assert.True(account.FixedAddresses!.All(e => account.GetPrivateKey(e.IsChange, e.Index) is null)); + } + + [Fact] + public void NewFromAddresses_lista_vuota_lancia_eccezione() + { + Assert.Throws( + () => WalletLoader.NewFromAddresses([], ChainProfiles.Mainnet)); + } + + [Fact] + public void NewFromAddresses_indirizzo_invalido_lancia_eccezione() + { + Assert.Throws( + () => WalletLoader.NewFromAddresses(["not-an-address"], ChainProfiles.Mainnet)); + } + + [Fact] + public void NewFromAddresses_indirizzo_di_rete_sbagliata_lancia_eccezione() + { + var testnetAddr = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Testnet) + .Account.GetReceiveAddress(0).ToString(); + Assert.Throws( + () => WalletLoader.NewFromAddresses([testnetAddr], ChainProfiles.Mainnet)); + } + + [Fact] + public void ToAccount_da_watch_addresses_ricostruisce_lo_stesso_account() + { + var address = SampleAddress(); + var (doc, _) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet); + + var account = WalletLoader.ToAccount(doc); + + Assert.True(account.IsWatchOnly); + Assert.Equal(address, account.GetReceiveAddress(0).ToString()); + Assert.Null(account.GetPrivateKey(false, 0)); + } }