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));
+ }
}