Add support for Ledger Nano X
MODIFY
WalletWasabi.Fluent/ViewModels/AddWallet/HardwareWallet/DetectedHardwareWalletViewModel.cs
WalletWasabi.Fluent/ViewModels/AddWallet/HardwareWallet/DetectedHardwareWalletViewModel.cs
@@ -24,7 +24,7 @@ namespace WalletWasabi.Fluent.ViewModels.AddWallet.HardwareWallet
Type = device.Model switch
{
HardwareWalletModels.Coldcard or HardwareWalletModels.Coldcard_Simulator => WalletType.Coldcard,
- HardwareWalletModels.Ledger_Nano_S => WalletType.Ledger,
+ HardwareWalletModels.Ledger_Nano_S or HardwareWalletModels.Ledger_Nano_X => WalletType.Ledger,
HardwareWalletModels.Trezor_1 or HardwareWalletModels.Trezor_1_Simulator or HardwareWalletModels.Trezor_T or HardwareWalletModels.Trezor_T_Simulator => WalletType.Trezor,
_ => WalletType.Hardware
};

MODIFY
WalletWasabi.Gui/Tabs/WalletManager/HardwareWallets/ConnectHardwareWalletView.xaml
WalletWasabi.Gui/Tabs/WalletManager/HardwareWallets/ConnectHardwareWalletView.xaml
</StackPanel>
<StackPanel>
<TextBlock Text="Which hardware wallets are supported?" FontWeight="Bold" />
- <TextBlock Text="- Coldcard and Ledger Nano S." TextWrapping="Wrap" />
+ <TextBlock Text="- Coldcard, Ledger Nano S and Ledger Nano X." TextWrapping="Wrap" />
<TextBlock Text="- While other hardware wallets may work, they were not tested by Wasabi developers." TextWrapping="Wrap" />
</StackPanel>
</StackPanel>

MODIFY
WalletWasabi.Tests/AcceptanceTests/HwiKatas.cs
WalletWasabi.Tests/AcceptanceTests/HwiKatas.cs
@@ -292,5 +292,88 @@ namespace WalletWasabi.Tests.AcceptanceTests
var checkResult = signedTx.Check();
Assert.Equal(TransactionCheckResult.Success, checkResult);
}
+
+ [Fact]
+ public async Task LedgerNanoXKataAsync()
+ {
+ // --- USER INTERACTIONS ---
+ //
+ // Connect and initialize your Nano X with the following seed phrase:
+ // more maid moon upgrade layer alter marine screen benefit way cover alcohol
+ // Run this test.
+ // displayaddress request(derivation path): approve
+ // displayaddress request: reject
+ // displayaddress request(derivation path): approve
+ // displayaddress request: approve
+ // displayaddress request(derivation path): approve
+ // displayaddress request: approve
+ // signtx request: reject
+ // signtx request: accept
+ // confirm transaction: accept and send
+ //
+ // --- USER INTERACTIONS ---
+
+ var network = Network.Main;
+ var client = new HwiClient(network);
+ using var cts = new CancellationTokenSource(ReasonableRequestTimeout);
+ var enumerate = await client.EnumerateAsync(cts.Token);
+ HwiEnumerateEntry entry = Assert.Single(enumerate);
+ Assert.NotNull(entry.Path);
+ Assert.Equal(HardwareWalletModels.Ledger_Nano_X, entry.Model);
+ Assert.NotNull(entry.Fingerprint);
+ Assert.Null(entry.Code);
+ Assert.Null(entry.Error);
+ Assert.Null(entry.SerialNumber);
+ Assert.False(entry.NeedsPassphraseSent);
+ Assert.False(entry.NeedsPinSent);
+
+ string devicePath = entry.Path;
+ HardwareWalletModels deviceType = entry.Model;
+ HDFingerprint fingerprint = entry.Fingerprint.Value;
+
+ await Assert.ThrowsAsync<HwiException>(async () => await client.SetupAsync(deviceType, devicePath, false, cts.Token));
+
+ await Assert.ThrowsAsync<HwiException>(async () => await client.RestoreAsync(deviceType, devicePath, false, cts.Token));
+
+ await Assert.ThrowsAsync<HwiException>(async () => await client.PromptPinAsync(deviceType, devicePath, cts.Token));
+
+ await Assert.ThrowsAsync<HwiException>(async () => await client.SendPinAsync(deviceType, devicePath, 1111, cts.Token));
+
+ KeyPath keyPath1 = KeyManager.DefaultAccountKeyPath;
+ KeyPath keyPath2 = KeyManager.DefaultAccountKeyPath.Derive(1);
+ ExtPubKey xpub1 = await client.GetXpubAsync(deviceType, devicePath, keyPath1, cts.Token);
+ ExtPubKey xpub2 = await client.GetXpubAsync(deviceType, devicePath, keyPath2, cts.Token);
+ Assert.NotNull(xpub1);
+ Assert.NotNull(xpub2);
+ Assert.NotEqual(xpub1, xpub2);
+
+ // USER SHOULD REFUSE ACTION
+ await Assert.ThrowsAsync<HwiException>(async () => await client.DisplayAddressAsync(deviceType, devicePath, keyPath1, cts.Token));
+
+ // USER: CONFIRM
+ BitcoinWitPubKeyAddress address1 = await client.DisplayAddressAsync(deviceType, devicePath, keyPath1, cts.Token);
+ // USER: CONFIRM
+ BitcoinWitPubKeyAddress address2 = await client.DisplayAddressAsync(fingerprint, keyPath2, cts.Token);
+ Assert.NotNull(address1);
+ Assert.NotNull(address2);
+ Assert.NotEqual(address1, address2);
+ var expectedAddress1 = xpub1.PubKey.GetAddress(ScriptPubKeyType.Segwit, network);
+ var expectedAddress2 = xpub2.PubKey.GetAddress(ScriptPubKeyType.Segwit, network);
+ Assert.Equal(expectedAddress1, address1);
+ Assert.Equal(expectedAddress2, address2);
+
+ // USER: REFUSE
+ var ex = await Assert.ThrowsAsync<HwiException>(async () => await client.SignTxAsync(deviceType, devicePath, Psbt, cts.Token));
+ Assert.Equal(HwiErrorCode.BadArgument, ex.ErrorCode);
+
+ // USER: CONFIRM
+ PSBT signedPsbt = await client.SignTxAsync(deviceType, devicePath, Psbt, cts.Token);
+
+ Transaction signedTx = signedPsbt.GetOriginalTransaction();
+ Assert.Equal(Psbt.GetOriginalTransaction().GetHash(), signedTx.GetHash());
+
+ var checkResult = signedTx.Check();
+ Assert.Equal(TransactionCheckResult.Success, checkResult);
+ }
}
}

MODIFY
WalletWasabi.Tests/UnitTests/Hwi/HwiProcessBridgeMock.cs
WalletWasabi.Tests/UnitTests/Hwi/HwiProcessBridgeMock.cs
@@ -48,6 +48,11 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
model = "ledger_nano_s";
rawPath = "\\\\\\\\?\\\\hid#vid_2c97&pid_0001&mi_00#7&e45ae20&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ model = "ledger_nano_x";
+ rawPath = "\\\\\\\\?\\\\hid#vid_2c97&pid_0001&mi_00#7&e45ae20&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}";
+ }
else
{
throw new NotImplementedException("Mock missing.");
@@ -79,6 +84,10 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = $"[{{\"model\": \"{model}\", \"path\": \"{rawPath}\", \"fingerprint\": \"4054d6f6\", \"needs_pin_sent\": false, \"needs_passphrase_sent\": false}}]\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = $"[{{\"model\": \"{model}\", \"path\": \"{rawPath}\", \"fingerprint\": \"4054d6f6\", \"needs_pin_sent\": false, \"needs_passphrase_sent\": false}}]\r\n";
+ }
}
else if (CompareArguments(arguments, $"{devicePathAndTypeArgumentString} wipe"))
{
@@ -94,6 +103,10 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = "{\"error\": \"The Ledger Nano S does not support wiping via software\", \"code\": -9}\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = "{\"error\": \"The Ledger Nano X does not support wiping via software\", \"code\": -9}\r\n";
+ }
}
else if (CompareArguments(arguments, $"{devicePathAndTypeArgumentString} setup"))
{
@@ -109,6 +122,10 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = "{\"error\": \"The Ledger Nano S does not support software setup\", \"code\": -9}\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = "{\"error\": \"The Ledger Nano X does not support software setup\", \"code\": -9}\r\n";
+ }
}
else if (CompareArguments(arguments, $"{devicePathAndTypeArgumentString} --interactive setup"))
{
@@ -124,6 +141,10 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = "{\"error\": \"The Ledger Nano S does not support software setup\", \"code\": -9}\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = "{\"error\": \"The Ledger Nano X does not support software setup\", \"code\": -9}\r\n";
+ }
}
else if (CompareArguments(arguments, $"{devicePathAndTypeArgumentString} --interactive restore"))
{
@@ -139,6 +160,10 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = "{\"error\": \"The Ledger Nano S does not support restoring via software\", \"code\": -9}\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = "{\"error\": \"The Ledger Nano X does not support restoring via software\", \"code\": -9}\r\n";
+ }
}
else if (CompareArguments(arguments, $"{devicePathAndTypeArgumentString} promptpin"))
{
@@ -154,6 +179,10 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = "{\"error\": \"The Ledger Nano S does not need a PIN sent from the host\", \"code\": -9}\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = "{\"error\": \"The Ledger Nano X does not need a PIN sent from the host\", \"code\": -9}\r\n";
+ }
}
else if (CompareArguments(arguments, $"{devicePathAndTypeArgumentString} sendpin", true))
{
@@ -169,17 +198,21 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
{
response = "{\"error\": \"The Ledger Nano S does not need a PIN sent from the host\", \"code\": -9}\r\n";
}
+ else if (Model == HardwareWalletModels.Ledger_Nano_X)
+ {
+ response = "{\"error\": \"The Ledger Nano X does not need a PIN sent from the host\", \"code\": -9}\r\n";
+ }
}
else if (CompareGetXbpubArguments(arguments, out string? xpub))
{
- if (Model is HardwareWalletModels.Trezor_T or HardwareWalletModels.Coldcard or HardwareWalletModels.Trezor_1 or HardwareWalletModels.Ledger_Nano_S)
+ if (Model is HardwareWalletModels.Trezor_T or HardwareWalletModels.Coldcard or HardwareWalletModels.Trezor_1 or HardwareWalletModels.Ledger_Nano_S or HardwareWalletModels.Ledger_Nano_X)
{
response = $"{{\"xpub\": \"{xpub}\"}}\r\n";
}
}
else if (CompareArguments(out bool t1, arguments, $"{devicePathAndTypeArgumentString} displayaddress --path m/84h/0h/0h --wpkh", false))
{
- if (Model is HardwareWalletModels.Trezor_T or HardwareWalletModels.Coldcard or HardwareWalletModels.Trezor_1 or HardwareWalletModels.Ledger_Nano_S)
+ if (Model is HardwareWalletModels.Trezor_T or HardwareWalletModels.Coldcard or HardwareWalletModels.Trezor_1 or HardwareWalletModels.Ledger_Nano_S or HardwareWalletModels.Ledger_Nano_X)
{
response = t1
? "{\"address\": \"tb1q7zqqsmqx5ymhd7qn73lm96w5yqdkrmx7rtzlxy\"}\r\n"
@@ -188,7 +221,7 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
}
else if (CompareArguments(out bool t2, arguments, $"{devicePathAndTypeArgumentString} displayaddress --path m/84h/0h/0h/1 --wpkh", false))
{
- if (Model is HardwareWalletModels.Trezor_T or HardwareWalletModels.Coldcard or HardwareWalletModels.Trezor_1 or HardwareWalletModels.Ledger_Nano_S)
+ if (Model is HardwareWalletModels.Trezor_T or HardwareWalletModels.Coldcard or HardwareWalletModels.Trezor_1 or HardwareWalletModels.Ledger_Nano_S or HardwareWalletModels.Ledger_Nano_X)
{
response = t2
? "{\"address\": \"tb1qmaveee425a5xjkjcv7m6d4gth45jvtnjqhj3l6\"}\r\n"

MODIFY
WalletWasabi.Tests/UnitTests/Hwi/MockedDeviceTests.cs
WalletWasabi.Tests/UnitTests/Hwi/MockedDeviceTests.cs
@@ -340,6 +340,86 @@ namespace WalletWasabi.Tests.UnitTests.Hwi
Assert.Equal(expectedAddress2, address2);
}
+ [Theory]
+ [MemberData(nameof(GetDifferentNetworkValues))]
+ public async Task LedgerNanoXTestsAsync(Network network)
+ {
+ var client = new HwiClient(network, new HwiProcessBridgeMock(HardwareWalletModels.Ledger_Nano_X));
+
+ using var cts = new CancellationTokenSource(ReasonableRequestTimeout);
+ IEnumerable<HwiEnumerateEntry> enumerate = await client.EnumerateAsync(cts.Token);
+ Assert.Single(enumerate);
+ HwiEnumerateEntry entry = enumerate.Single();
+ Assert.Equal(HardwareWalletModels.Ledger_Nano_X, entry.Model);
+ Assert.Equal(@"\\?\hid#vid_2c97&pid_0001&mi_00#7&e45ae20&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}", entry.Path);
+ Assert.False(entry.NeedsPassphraseSent);
+ Assert.False(entry.NeedsPinSent);
+ Assert.Null(entry.Error);
+ Assert.Null(entry.Code);
+ Assert.True(entry.IsInitialized());
+ Assert.Equal("4054d6f6", entry.Fingerprint.ToString());
+
+ var deviceType = entry.Model;
+ var devicePath = entry.Path;
+
+ var wipe = await Assert.ThrowsAsync<HwiException>(async () => await client.WipeAsync(deviceType, devicePath, cts.Token));
+ Assert.Equal("The Ledger Nano X does not support wiping via software", wipe.Message);
+ Assert.Equal(HwiErrorCode.UnavailableAction, wipe.ErrorCode);
+
+ var setup = await Assert.ThrowsAsync<HwiException>(async () => await client.SetupAsync(deviceType, devicePath, false, cts.Token));
+ Assert.Equal("The Ledger Nano X does not support software setup", setup.Message);
+ Assert.Equal(HwiErrorCode.UnavailableAction, setup.ErrorCode);
+
+ var restore = await Assert.ThrowsAsync<HwiException>(async () => await client.RestoreAsync(deviceType, devicePath, false, cts.Token));
+ Assert.Equal("The Ledger Nano X does not support restoring via software", restore.Message);
+ Assert.Equal(HwiErrorCode.UnavailableAction, restore.ErrorCode);
+
+ var promptpin = await Assert.ThrowsAsync<HwiException>(async () => await client.PromptPinAsync(deviceType, devicePath, cts.Token));
+ Assert.Equal("The Ledger Nano X does not need a PIN sent from the host", promptpin.Message);
+ Assert.Equal(HwiErrorCode.UnavailableAction, promptpin.ErrorCode);
+
+ var sendpin = await Assert.ThrowsAsync<HwiException>(async () => await client.SendPinAsync(deviceType, devicePath, 1111, cts.Token));
+ Assert.Equal("The Ledger Nano X does not need a PIN sent from the host", sendpin.Message);
+ Assert.Equal(HwiErrorCode.UnavailableAction, sendpin.ErrorCode);
+
+ KeyPath keyPath1 = KeyManager.DefaultAccountKeyPath;
+ KeyPath keyPath2 = KeyManager.DefaultAccountKeyPath.Derive(1);
+ ExtPubKey xpub1 = await client.GetXpubAsync(deviceType, devicePath, keyPath1, cts.Token);
+ ExtPubKey xpub2 = await client.GetXpubAsync(deviceType, devicePath, keyPath2, cts.Token);
+ var expecteXpub1 = NBitcoinHelpers.BetterParseExtPubKey("xpub6DHjDx4gzLV37gJWMxYJAqyKRGN46MT61RHVizdU62cbVUYu9L95cXKzX62yJ2hPbN11EeprS8sSn8kj47skQBrmycCMzFEYBQSntVKFQ5M");
+ var expecteXpub2 = NBitcoinHelpers.BetterParseExtPubKey("xpub6FJS1ne3STcKdQ9JLXNzZXidmCNZ9dxLiy7WVvsRkcmxjJsrDKJKEAXq4MGyEBM3vHEw2buqXezfNK5SNBrkwK7Fxjz1TW6xzRr2pUyMWFu");
+ Assert.Equal(expecteXpub1, xpub1);
+ Assert.Equal(expecteXpub2, xpub2);
+
+ BitcoinWitPubKeyAddress address1 = await client.DisplayAddressAsync(deviceType, devicePath, keyPath1, cts.Token);
+ BitcoinWitPubKeyAddress address2 = await client.DisplayAddressAsync(deviceType, devicePath, keyPath2, cts.Token);
+
+ BitcoinAddress expectedAddress1;
+ BitcoinAddress expectedAddress2;
+ if (network == Network.Main)
+ {
+ expectedAddress1 = BitcoinAddress.Create("bc1q7zqqsmqx5ymhd7qn73lm96w5yqdkrmx7fdevah", Network.Main);
+ expectedAddress2 = BitcoinAddress.Create("bc1qmaveee425a5xjkjcv7m6d4gth45jvtnj23fzyf", Network.Main);
+ }
+ else if (network == Network.TestNet)
+ {
+ expectedAddress1 = BitcoinAddress.Create("tb1q7zqqsmqx5ymhd7qn73lm96w5yqdkrmx7rtzlxy", Network.TestNet);
+ expectedAddress2 = BitcoinAddress.Create("tb1qmaveee425a5xjkjcv7m6d4gth45jvtnjqhj3l6", Network.TestNet);
+ }
+ else if (network == Network.RegTest)
+ {
+ expectedAddress1 = BitcoinAddress.Create("bcrt1q7zqqsmqx5ymhd7qn73lm96w5yqdkrmx7pzmj3d", Network.RegTest);
+ expectedAddress2 = BitcoinAddress.Create("bcrt1qmaveee425a5xjkjcv7m6d4gth45jvtnjz7tugn", Network.RegTest);
+ }
+ else
+ {
+ throw new NotSupportedNetworkException(network);
+ }
+
+ Assert.Equal(expectedAddress1, address1);
+ Assert.Equal(expectedAddress2, address2);
+ }
+
#endregion Tests
#region HelperMethods

MODIFY
WalletWasabi.Tests/UnitTests/WalletDirTests.cs
WalletWasabi.Tests/UnitTests/WalletDirTests.cs
@@ -218,6 +218,7 @@ namespace WalletWasabi.Tests.UnitTests
Assert.Equal("KeepKey", HardwareWalletModels.KeepKey.FriendlyName());
Assert.Equal("KeepKey Simulator", HardwareWalletModels.KeepKey_Simulator.FriendlyName());
Assert.Equal("Ledger Nano S", HardwareWalletModels.Ledger_Nano_S.FriendlyName());
+ Assert.Equal("Ledger Nano X", HardwareWalletModels.Ledger_Nano_X.FriendlyName());
Assert.Equal("Trezor One", HardwareWalletModels.Trezor_1.FriendlyName());
Assert.Equal("Trezor One Simulator", HardwareWalletModels.Trezor_1_Simulator.FriendlyName());
Assert.Equal("Trezor T", HardwareWalletModels.Trezor_T.FriendlyName());

MODIFY
WalletWasabi/Hwi/Models/HardwareWalletModels.cs
WalletWasabi/Hwi/Models/HardwareWalletModels.cs
@@ -31,6 +31,9 @@ namespace WalletWasabi.Hwi.Models
[Description("Ledger Nano S")]
Ledger_Nano_S,
+ [Description("Ledger Nano X")]
+ Ledger_Nano_X,
+
[Description("Trezor One")]
Trezor_1,

Add support for Ledger Nano X