feat(wallet): add P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86) address types

- ScriptKind.Taproot (BIP86, purpose 86', P2WPKH → witness v1 bech32m plm1p)
  derivation wired in DerivationPaths and ChainProfiles (xprv/xpub headers,
  no SLIP-132 standard for P2TR); NBitcoin ScriptPubKeyType.TaprootBIP86
- Wizard step StepScriptType inserted between passphrase and password: 4 radio
  buttons (Legacy / Wrapped SegWit / Native SegWit★ / Taproot), localized in
  6 languages; CreateOrRestore now uses SelectedScriptKind instead of hardcoded
  NativeSegwit; back navigation updated accordingly
- BIP86 golden-vector tests (Bip86TaprootTests): output key verified against
  official Bitcoin BIP86 addresses, plm1p prefix asserted
This commit is contained in:
2026-06-15 12:03:46 +02:00
parent 8b960458ee
commit 002c854497
7 changed files with 198 additions and 9 deletions
+12
View File
@@ -110,6 +110,18 @@ public sealed class Loc
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
["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"],
// Pannello wallet
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
@@ -23,6 +23,7 @@ public partial class MainWindowViewModel
public const string StepConfirmSeed = "confirm-seed";
public const string StepWords = "words";
public const string StepPassphrase = "passphrase";
public const string StepScriptType = "script-type";
public const string StepPassword = "password";
[ObservableProperty]
@@ -34,18 +35,37 @@ public partial class MainWindowViewModel
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
[NotifyPropertyChangedFor(nameof(IsStepWords))]
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart;
public bool IsStepDataLocation => SetupStep == StepDataLocation;
public bool IsStepStart => SetupStep == StepStart;
public bool IsStepStart => SetupStep == StepStart;
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
public bool IsStepOpen => SetupStep == StepOpen;
public bool IsStepShowSeed => SetupStep == StepShowSeed;
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
public bool IsStepWords => SetupStep == StepWords;
public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepPassword => SetupStep == StepPassword;
public bool IsStepOpen => SetupStep == StepOpen;
public bool IsStepShowSeed => SetupStep == StepShowSeed;
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
public bool IsStepWords => SetupStep == StepWords;
public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepScriptType => SetupStep == StepScriptType;
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();
@@ -96,6 +116,7 @@ public partial class MainWindowViewModel
private void RefreshSetupState()
{
SetupStep = StepStart;
SelectedScriptKind = ScriptKind.NativeSegwit;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
StatusMessage = "";
@@ -192,6 +213,13 @@ public partial class MainWindowViewModel
[RelayCommand]
private void WizardNextFromPassphrase()
{
SetupStep = StepScriptType;
StatusMessage = "";
}
[RelayCommand]
private void WizardNextFromScriptType()
{
PasswordInput = ConfirmPasswordInput = "";
EncryptWallet = true;
@@ -208,7 +236,8 @@ public partial class MainWindowViewModel
StepChooseWallet or StepShowSeed or StepWords => StepStart,
StepConfirmSeed => StepShowSeed,
StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed,
StepPassword => StepPassphrase,
StepScriptType => StepPassphrase,
StepPassword => StepScriptType,
_ => StepStart,
};
if (SetupStep == StepStart)
@@ -243,7 +272,7 @@ public partial class MainWindowViewModel
var (doc, account) = WalletLoader.NewFromMnemonic(
MnemonicInput,
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
ScriptKind.NativeSegwit,
SelectedScriptKind,
Profile);
var path = AppPaths.DefaultWalletPath(Net);
for (var n = 2; WalletStore.Exists(path); n++)
+48
View File
@@ -171,6 +171,54 @@
</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: password del file (cifratura, stile Electrum) -->
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
+3
View File
@@ -29,6 +29,9 @@ public enum ScriptKind
/// <summary>P2WSH (multisig native).</summary>
NativeSegwitMultisig,
/// <summary>P2TR key-path only (Taproot, BIP86) — witness v1, bech32m.</summary>
Taproot,
}
/// <summary>
+3
View File
@@ -33,6 +33,8 @@ public static class ChainProfiles
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // 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
// peer vengono scoperti via server.peers.subscribe.
@@ -70,6 +72,7 @@ public static class ChainProfiles
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
},
BootstrapServers = [],
Checkpoints = [],
+5
View File
@@ -22,12 +22,16 @@ public static class DerivationPaths
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
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
{
ScriptKind.Legacy => PurposeLegacy,
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
ScriptKind.NativeSegwit => PurposeNativeSegwit,
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
ScriptKind.Taproot => PurposeTaproot,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
@@ -41,6 +45,7 @@ public static class DerivationPaths
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
throw new NotSupportedException(
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
@@ -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));
}
}