add bitkey support

This commit is contained in:
harrisonfriia 2025-08-28 15:34:44 -07:00
parent 2c27112dad
commit ddb4b53ef5
8 changed files with 262 additions and 3 deletions

View file

@ -49,8 +49,6 @@ import javafx.geometry.Side;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*; import javafx.scene.input.*;
import javafx.scene.layout.*; import javafx.scene.layout.*;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
@ -1230,6 +1228,7 @@ public class AppController implements Initializable {
new CoboVaultSinglesig(), new CoboVaultMultisig(), new CoboVaultSinglesig(), new CoboVaultMultisig(),
new PassportSinglesig(), new PassportSinglesig(),
new KeystoneSinglesig(), new KeystoneMultisig(), new KeystoneSinglesig(), new KeystoneMultisig(),
new BitkeyMultisig(),
new CaravanMultisig()); new CaravanMultisig());
for(WalletImport importer : walletImporters) { for(WalletImport importer : walletImporters) {
if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) { if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) {

View file

@ -61,7 +61,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
} }
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), List<WalletImport> 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()) { if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms)); walletImporters.add(new WalletLabels(selectedWalletForms));
} }

View file

@ -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<String> 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<Keystore> 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;
}
}

View file

@ -0,0 +1,10 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3323_17112)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.6399 7.17173C25.2437 6.94276 24.7554 6.94276 24.3591 7.17173L17.1591 11.3322C16.7633 11.561 16.5195 11.9834 16.5195 12.4405V20.7634C16.5195 21.2206 16.7633 21.643 17.1591 21.8718L22.0415 24.693C22.4373 24.9218 22.6811 25.3442 22.6811 25.8013V41.7198C22.6811 42.4267 23.2542 42.9998 23.9611 42.9998H26.038C26.7449 42.9998 27.318 42.4267 27.318 41.7198V25.8013C27.318 25.3442 27.5618 24.9218 27.9575 24.693L32.8399 21.8718C33.2358 21.643 33.4795 21.2206 33.4795 20.7634V12.4405C33.4795 11.9834 33.2358 11.561 32.8399 11.3322L25.6399 7.17173ZM30.2859 34.0713C29.6459 33.6981 28.8423 34.1598 28.8423 34.9006V35.469V39.9481V40.5166C28.8423 41.2574 29.6459 41.719 30.2859 41.3458L32.2065 40.2258C32.5015 40.0538 32.683 39.7381 32.683 39.3966V36.0206C32.683 35.679 32.5015 35.3633 32.2065 35.1912L30.2859 34.0713ZM25.4851 12.4493C25.1863 12.275 24.8168 12.275 24.518 12.4493L21.6373 14.129C21.3423 14.301 21.1608 14.6168 21.1608 14.9583V18.3344C21.1608 18.6758 21.3423 18.9917 21.6373 19.1637L24.518 20.8434C24.8168 21.0177 25.1863 21.0177 25.4851 20.8434L28.3658 19.1637C28.6608 18.9917 28.8423 18.6758 28.8423 18.3344V14.9583C28.8423 14.6168 28.6608 14.301 28.3658 14.129L25.4851 12.4493Z" fill="#D6D2D2"/>
</g>
<defs>
<clipPath id="clip0_3323_17112">
<rect width="16.96" height="36" fill="white" transform="translate(16.5195 7)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,10 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3323_17111)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.6409 7.17173C25.2447 6.94276 24.7563 6.94276 24.3601 7.17173L17.1601 11.3322C16.7643 11.561 16.5205 11.9834 16.5205 12.4405V20.7634C16.5205 21.2206 16.7643 21.643 17.1601 21.8718L22.0425 24.693C22.4383 24.9218 22.6821 25.3442 22.6821 25.8013V41.7198C22.6821 42.4267 23.2551 42.9998 23.9621 42.9998H26.039C26.7459 42.9998 27.319 42.4267 27.319 41.7198V25.8013C27.319 25.3442 27.5627 24.9218 27.9585 24.693L32.8409 21.8718C33.2367 21.643 33.4805 21.2206 33.4805 20.7634V12.4405C33.4805 11.9834 33.2367 11.561 32.8409 11.3322L25.6409 7.17173ZM30.2868 34.0713C29.6468 33.6981 28.8432 34.1598 28.8432 34.9006V35.469V39.9481V40.5166C28.8432 41.2574 29.6468 41.719 30.2868 41.3458L32.2075 40.2258C32.5025 40.0538 32.6839 39.7381 32.6839 39.3966V36.0206C32.6839 35.679 32.5025 35.3633 32.2075 35.1912L30.2868 34.0713ZM25.4861 12.4493C25.1872 12.275 24.8178 12.275 24.5189 12.4493L21.6383 14.129C21.3432 14.301 21.1618 14.6168 21.1618 14.9583V18.3344C21.1618 18.6758 21.3432 18.9917 21.6383 19.1637L24.5189 20.8434C24.8178 21.0177 25.1872 21.0177 25.4861 20.8434L28.3667 19.1637C28.6618 18.9917 28.8432 18.6758 28.8432 18.3344V14.9583C28.8432 14.6168 28.6618 14.301 28.3667 14.129L25.4861 12.4493Z" fill="#242424"/>
</g>
<defs>
<clipPath id="clip0_3323_17111">
<rect width="16.96" height="36" fill="white" transform="translate(16.5205 7)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

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

View file

@ -0,0 +1,2 @@
This is not a valid Bitkey export format.
It should contain External: and Internal: lines with descriptors.

View file

@ -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/*))