feat(core): support pure watch-only address import
Adds a WalletDocument.WatchAddresses field and WalletLoader.NewFromAddresses to build an ImportedKeyAccount with no private key at all (unlike xpub/WIF imports, which can still derive/hold key material). ScriptKind is inferred from the address itself via a new DerivationPaths.KindFor helper. Signing already refuses to run for any IsWatchOnly account (TransactionFactory), so this only had to wire up construction/reload of the new account shape.
This commit is contained in:
@@ -52,6 +52,19 @@ public static class DerivationPaths
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static ScriptKind KindFor(BitcoinAddress address) => address switch
|
||||||
|
{
|
||||||
|
BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit,
|
||||||
|
TaprootAddress => ScriptKind.Taproot,
|
||||||
|
BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig,
|
||||||
|
BitcoinScriptAddress => ScriptKind.WrappedSegwit,
|
||||||
|
_ => ScriptKind.Legacy,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
||||||
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public sealed class WalletDocument
|
|||||||
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
||||||
public List<string>? WifKeys { get; set; }
|
public List<string>? WifKeys { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Watch-only addresses with no associated private key (pure address import).</summary>
|
||||||
|
public List<string>? WatchAddresses { get; set; }
|
||||||
|
|
||||||
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
||||||
public int GapLimit { get; set; } = 20;
|
public int GapLimit { get; set; } = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,15 @@ public static class WalletLoader
|
|||||||
return new ImportedKeyAccount(entries, kind, profile);
|
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)
|
if (doc.AccountXpub is null)
|
||||||
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
||||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||||
@@ -170,4 +178,47 @@ public static class WalletLoader
|
|||||||
};
|
};
|
||||||
return (doc, account);
|
return (doc, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <paramref name="kindOverride"/> is given.
|
||||||
|
/// </summary>
|
||||||
|
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses(
|
||||||
|
IReadOnlyList<string> 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<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,21 @@ public class StorageTests
|
|||||||
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
|
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]
|
[Fact]
|
||||||
public void Json_corrotto_lancia_eccezione()
|
public void Json_corrotto_lancia_eccezione()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -250,6 +250,33 @@ public class TransactionFactoryTests
|
|||||||
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
|
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]
|
[Theory]
|
||||||
[InlineData("0.00000001", 1L)]
|
[InlineData("0.00000001", 1L)]
|
||||||
[InlineData("1", 100_000_000L)]
|
[InlineData("1", 100_000_000L)]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
@@ -249,4 +250,85 @@ public class WalletLoaderTests
|
|||||||
Assert.Throws<InvalidDataException>(
|
Assert.Throws<InvalidDataException>(
|
||||||
() => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
|
() => 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<InvalidDataException>(
|
||||||
|
() => WalletLoader.NewFromAddresses([], ChainProfiles.Mainnet));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromAddresses_indirizzo_invalido_lancia_eccezione()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidDataException>(
|
||||||
|
() => 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<InvalidDataException>(
|
||||||
|
() => 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user