From ddb4b53ef5b6737fb4ccd4d66fbb9cf9bec5abeb Mon Sep 17 00:00:00 2001 From: harrisonfriia <5942798+harrisonfriia@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:34:44 -0700 Subject: [PATCH] add bitkey support --- .../sparrowwallet/sparrow/AppController.java | 3 +- .../sparrow/control/WalletImportDialog.java | 2 +- .../sparrow/io/BitkeyMultisig.java | 171 ++++++++++++++++++ .../image/walletmodel/bitkey-invert.svg | 10 + .../resources/image/walletmodel/bitkey.svg | 10 + .../sparrow/io/BitkeyMultisigTest.java | 65 +++++++ .../sparrow/io/bitkey-invalid-format.txt | 2 + .../sparrow/io/bitkey-multisig-export.txt | 2 + 8 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/BitkeyMultisig.java create mode 100644 src/main/resources/image/walletmodel/bitkey-invert.svg create mode 100644 src/main/resources/image/walletmodel/bitkey.svg create mode 100644 src/test/java/com/sparrowwallet/sparrow/io/BitkeyMultisigTest.java create mode 100644 src/test/resources/com/sparrowwallet/sparrow/io/bitkey-invalid-format.txt create mode 100644 src/test/resources/com/sparrowwallet/sparrow/io/bitkey-multisig-export.txt diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 84aa0060..fa344d81 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -49,8 +49,6 @@ import javafx.geometry.Side; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.*; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; import javafx.scene.input.*; import javafx.scene.layout.*; import javafx.scene.paint.Color; @@ -1230,6 +1228,7 @@ public class AppController implements Initializable { new CoboVaultSinglesig(), new CoboVaultMultisig(), new PassportSinglesig(), new KeystoneSinglesig(), new KeystoneMultisig(), + new BitkeyMultisig(), new CaravanMultisig()); for(WalletImport importer : walletImporters) { if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java index eef5a156..33490718 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java @@ -61,7 +61,7 @@ public class WalletImportDialog extends Dialog { } List walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), - new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig(), new PassportMultisig(), new SpecterDIYMultisig())); + new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig(), new PassportMultisig(), new SpecterDIYMultisig(), new BitkeyMultisig())); if(!selectedWalletForms.isEmpty()) { walletImporters.add(new WalletLabels(selectedWalletForms)); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/BitkeyMultisig.java b/src/main/java/com/sparrowwallet/sparrow/io/BitkeyMultisig.java new file mode 100644 index 00000000..1d4594d3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/BitkeyMultisig.java @@ -0,0 +1,171 @@ +package com.sparrowwallet.sparrow.io; + +import com.google.common.io.CharStreams; +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.policy.Policy; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class BitkeyMultisig implements WalletImport, KeystoreFileImport { + private static final Logger log = LoggerFactory.getLogger(BitkeyMultisig.class); + + @Override + public String getName() { + return "Bitkey Multisig"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.BITKEY; + } + + @Override + public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException { + throw new ImportException("Bitkey export format is for full wallets, not individual keystores in this context."); + } + + @Override + public String getKeystoreImportDescription(int account) { + return "Import the XPUB file exported from your Bitkey app. This file contains all co-signer xpubs for a multisig wallet."; + } + + @Override + public Wallet importWallet(InputStream inputStream, String password) throws ImportException { + Wallet wallet = new Wallet(); + wallet.setPolicyType(PolicyType.MULTI); + wallet.setName(getName()); + + try { + List lines = CharStreams.readLines(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + String externalDescriptorLine = null; + + for (String line : lines) { + if (line.startsWith("External: ")) { + externalDescriptorLine = line.substring("External: ".length()).trim(); + break; + } + } + + if (externalDescriptorLine == null) { + throw new ImportException("External descriptor line not found in Bitkey export file."); + } + + String descriptorContent; + if (externalDescriptorLine.startsWith("wsh(")) { + wallet.setScriptType(ScriptType.P2WSH); + descriptorContent = externalDescriptorLine.substring("wsh(".length(), externalDescriptorLine.length() - 1); // remove wsh() wrapper + } else if (externalDescriptorLine.startsWith("sh(wsh(")) { + wallet.setScriptType(ScriptType.P2SH_P2WSH); + descriptorContent = externalDescriptorLine.substring("sh(wsh(".length(), externalDescriptorLine.length() - 2); // remove sh(wsh()) wrapper + } else { + throw new ImportException("Unsupported script type in Bitkey descriptor: " + externalDescriptorLine); + } + + if (!descriptorContent.startsWith("sortedmulti(")) { + throw new ImportException("Could not find sortedmulti in descriptor: " + descriptorContent); + } + + String sortedMultiContent = descriptorContent.substring("sortedmulti(".length(), descriptorContent.length() - 1); + + int firstComma = sortedMultiContent.indexOf(','); + if (firstComma == -1) { + throw new ImportException("Could not parse threshold from sortedmulti: " + sortedMultiContent); + } + int threshold = Integer.parseInt(sortedMultiContent.substring(0, firstComma)); + + String xpubsData = sortedMultiContent.substring(firstComma + 1); + String[] xpubEntries = xpubsData.split(","); + + List keystores = new ArrayList<>(); + for (String entry : xpubEntries) { + entry = entry.trim(); + if (!entry.startsWith("[") || !entry.contains("]")) { + throw new ImportException("Invalid xpub entry format: " + entry); + } + + String fullDerivation = entry.substring(1, entry.indexOf(']')); + String xpubWithBip32Path = entry.substring(entry.indexOf(']') + 1); + + int slashIndex = fullDerivation.indexOf('/'); + if (slashIndex == -1) { + throw new ImportException("Invalid full derivation format (missing fingerprint/path separator '/'): " + fullDerivation); + } + String fingerprint = fullDerivation.substring(0, slashIndex); + String derivationPathSuffix = fullDerivation.substring(slashIndex + 1); + // The KeyDerivation class expects 'm/' prefix if it's not already there implicitly by being a full path. + // Since we have a suffix after the fingerprint, we prepend 'm/'. + String derivationPath = "m/" + derivationPathSuffix; + + // The xpub from descriptor might include /0/* or /1/*, remove it for the base xpub used in Keystore. + String baseXpub = xpubWithBip32Path; + if (baseXpub.endsWith("/0/*")) { + baseXpub = baseXpub.substring(0, baseXpub.length() - "/0/*".length()); + } else if (baseXpub.endsWith("/1/*")) { + baseXpub = baseXpub.substring(0, baseXpub.length() - "/1/*".length()); + } + + Keystore keystore = new Keystore(); + keystore.setLabel(WalletModel.BITKEY.toDisplayString() + " " + fingerprint.substring(0, Math.min(fingerprint.length(), 4))); + keystore.setSource(KeystoreSource.HW_AIRGAPPED); + keystore.setWalletModel(WalletModel.BITKEY); + keystore.setKeyDerivation(new KeyDerivation(fingerprint, derivationPath)); + + log.debug("Attempting to create ExtendedKey from baseXpub: [{}] for fingerprint: [{}] and path: [{}]", baseXpub, fingerprint, derivationPath); + try { + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(baseXpub)); + } catch (Exception e) { + log.error("Failed to create ExtendedKey from baseXpub: {}", baseXpub, e); + throw new ImportException("Failed to parse xpub: " + baseXpub, e); + } + wallet.makeLabelsUnique(keystore); + keystores.add(keystore); + } + + if (keystores.isEmpty()) { + throw new ImportException("No xpubs found in Bitkey descriptor: " + externalDescriptorLine); + } + wallet.getKeystores().addAll(keystores); + + Policy policy = Policy.getPolicy(PolicyType.MULTI, wallet.getScriptType(), wallet.getKeystores(), threshold); + wallet.setDefaultPolicy(policy); + + log.debug("Wallet assembled. Policy: {} ScriptType: {} Keystores: {} Threshold: {}", policy, wallet.getScriptType(), keystores.size(), threshold); + + return wallet; + } catch (Exception e) { + log.error("Error importing Bitkey Multisig wallet", e); + throw new ImportException("Error importing Bitkey Multisig wallet: " + e.getMessage(), e); + } + } + + @Override + public String getWalletImportDescription() { + return "Import the .txt file exported from your Bitkey companion app. This file describes a multisig wallet setup."; + } + + @Override + public boolean isEncrypted(File file) { + return false; + } + + @Override + public boolean isWalletImportScannable() { + return false; + } + + @Override + public boolean isKeystoreImportScannable() { + return false; + } +} \ No newline at end of file diff --git a/src/main/resources/image/walletmodel/bitkey-invert.svg b/src/main/resources/image/walletmodel/bitkey-invert.svg new file mode 100644 index 00000000..db9f50e9 --- /dev/null +++ b/src/main/resources/image/walletmodel/bitkey-invert.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/image/walletmodel/bitkey.svg b/src/main/resources/image/walletmodel/bitkey.svg new file mode 100644 index 00000000..218eebcf --- /dev/null +++ b/src/main/resources/image/walletmodel/bitkey.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/test/java/com/sparrowwallet/sparrow/io/BitkeyMultisigTest.java b/src/test/java/com/sparrowwallet/sparrow/io/BitkeyMultisigTest.java new file mode 100644 index 00000000..7e54cdaf --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/BitkeyMultisigTest.java @@ -0,0 +1,65 @@ +package com.sparrowwallet.sparrow.io; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletModel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class BitkeyMultisigTest extends IoTest { + + @BeforeEach + public void setUp() { + Network.set(Network.MAINNET); + } + + @Test + public void testImport() throws ImportException { + BitkeyMultisig bitkeyMultisig = new BitkeyMultisig(); + Wallet wallet = bitkeyMultisig.importWallet(getInputStream("bitkey-multisig-export.txt"), null); + + Assertions.assertNotNull(wallet); + Assertions.assertEquals("Bitkey Multisig", wallet.getName()); + Assertions.assertEquals(PolicyType.MULTI, wallet.getPolicyType()); + Assertions.assertEquals(ScriptType.P2WSH, wallet.getScriptType()); + Assertions.assertEquals(3, wallet.getKeystores().size()); + Assertions.assertEquals(2, wallet.getDefaultPolicy().getNumSignaturesRequired()); + + // Keystore 1 + Assertions.assertEquals("c11d36d2", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint()); + Assertions.assertEquals("m/84'/0'/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath()); + Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6D4Sz2PtJCj14ZmnCTzmGgMRttx2iU3MXbyCzEGetB2D4DsRz2Rk69Hp2M94Nhkn6QBZ5JTa1EW82CtjHeTe1wqQXMKYY6Pq7qVaz3uXdZ2"), wallet.getKeystores().get(0).getExtendedPublicKey()); + Assertions.assertEquals(WalletModel.BITKEY, wallet.getKeystores().get(0).getWalletModel()); + + // Keystore 2 + Assertions.assertEquals("647b89ea", wallet.getKeystores().get(1).getKeyDerivation().getMasterFingerprint()); + Assertions.assertEquals("m/84'/0'/0'", wallet.getKeystores().get(1).getKeyDerivation().getDerivationPath()); + Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6CbVC3uhwPsQfAMC6ZVqCZsEx4BtNESnCLe3N8WqxAp46J2kjptausZRr11dCVh2WHamnEPrC8haz4iLR4sveUcqQx2iUawK41AY5RgyK8z"), wallet.getKeystores().get(1).getExtendedPublicKey()); + Assertions.assertEquals(WalletModel.BITKEY, wallet.getKeystores().get(1).getWalletModel()); + + // Keystore 3 + Assertions.assertEquals("02bfb691", wallet.getKeystores().get(2).getKeyDerivation().getMasterFingerprint()); + Assertions.assertEquals("m/84'/0'/0'", wallet.getKeystores().get(2).getKeyDerivation().getDerivationPath()); + Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6DAGAFL3RnuYbzs9zbbDeGG1uEMj8tTpp8EEy4DMiPXHNaX5jVdeixoZN5jpDGbPa6a62LMqH4FCi9JDs4GF9qx89bRmVoHeznSfx7svqYN"), wallet.getKeystores().get(2).getExtendedPublicKey()); + Assertions.assertEquals(WalletModel.BITKEY, wallet.getKeystores().get(2).getWalletModel()); + } + + + + @Test + public void testInvalidFormat() throws ImportException { + BitkeyMultisig bitkeyMultisig = new BitkeyMultisig(); + Assertions.assertThrows(ImportException.class, () -> + bitkeyMultisig.importWallet(getInputStream("bitkey-invalid-format.txt"), null)); + } + + @AfterEach + public void tearDown() { + Network.set(null); + } +} \ No newline at end of file diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/bitkey-invalid-format.txt b/src/test/resources/com/sparrowwallet/sparrow/io/bitkey-invalid-format.txt new file mode 100644 index 00000000..d29478d9 --- /dev/null +++ b/src/test/resources/com/sparrowwallet/sparrow/io/bitkey-invalid-format.txt @@ -0,0 +1,2 @@ +This is not a valid Bitkey export format. +It should contain External: and Internal: lines with descriptors. \ No newline at end of file diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/bitkey-multisig-export.txt b/src/test/resources/com/sparrowwallet/sparrow/io/bitkey-multisig-export.txt new file mode 100644 index 00000000..e0bed18f --- /dev/null +++ b/src/test/resources/com/sparrowwallet/sparrow/io/bitkey-multisig-export.txt @@ -0,0 +1,2 @@ +External: wsh(sortedmulti(2,[c11d36d2/84'/0'/0']xpub6D4Sz2PtJCj14ZmnCTzmGgMRttx2iU3MXbyCzEGetB2D4DsRz2Rk69Hp2M94Nhkn6QBZ5JTa1EW82CtjHeTe1wqQXMKYY6Pq7qVaz3uXdZ2/0/*,[647b89ea/84'/0'/0']xpub6CbVC3uhwPsQfAMC6ZVqCZsEx4BtNESnCLe3N8WqxAp46J2kjptausZRr11dCVh2WHamnEPrC8haz4iLR4sveUcqQx2iUawK41AY5RgyK8z/0/*,[02bfb691/84'/0'/0']xpub6DAGAFL3RnuYbzs9zbbDeGG1uEMj8tTpp8EEy4DMiPXHNaX5jVdeixoZN5jpDGbPa6a62LMqH4FCi9JDs4GF9qx89bRmVoHeznSfx7svqYN/0/*)) +Internal: wsh(sortedmulti(2,[c11d36d2/84'/0'/0']xpub6D4Sz2PtJCj14ZmnCTzmGgMRttx2iU3MXbyCzEGetB2D4DsRz2Rk69Hp2M94Nhkn6QBZ5JTa1EW82CtjHeTe1wqQXMKYY6Pq7qVaz3uXdZ2/1/*,[647b89ea/84'/0'/0']xpub6CbVC3uhwPsQfAMC6ZVqCZsEx4BtNESnCLe3N8WqxAp46J2kjptausZRr11dCVh2WHamnEPrC8haz4iLR4sveUcqQx2iUawK41AY5RgyK8z/1/*,[02bfb691/84'/0'/0']xpub6DAGAFL3RnuYbzs9zbbDeGG1uEMj8tTpp8EEy4DMiPXHNaX5jVdeixoZN5jpDGbPa6a62LMqH4FCi9JDs4GF9qx89bRmVoHeznSfx7svqYN/1/*)) \ No newline at end of file