Married HD wallets: Bloom filter adjustments

Pull request: #115
Based on design notes:
https://groups.google.com/forum/#!msg/bitcoinj/Uxl-z40OLuQ/e2m4mEWR6gMJ
This commit is contained in:
troggy
2014-06-21 00:35:59 +04:00
committed by Mike Hearn
parent 2edf978af4
commit 736c4c9907
9 changed files with 262 additions and 23 deletions

View File

@@ -293,6 +293,8 @@ public class TransactionOutput extends ChildMessage implements Serializable {
if (script.isSentToRawPubKey()) {
byte[] pubkey = script.getPubKey();
return wallet.isPubKeyMine(pubkey);
} if (script.isPayToScriptHash()) {
return wallet.isPayToScriptHashMine(script.getPubKeyHash());
} else {
byte[] pubkeyHash = script.getPubKeyHash();
return wallet.isPubKeyHashMine(pubkeyHash);

View File

@@ -755,6 +755,27 @@ public class Wallet extends BaseTaggableObject implements Serializable, BlockCha
return findKeyFromPubKey(pubkey) != null;
}
/**
* <p>Locates a script from the KeyChainGroup given the script hash. This is needed when finding out which
* script we need to use to redeem a transaction output.</p>
* Returns null if no such script found
*/
@Nullable
public Script findRedeemScriptFromPubHash(byte[] payToScriptHash) {
lock.lock();
try {
return keychain.findRedeemScriptFromPubHash(payToScriptHash);
} finally {
lock.unlock();
}
}
/**
* Returns true if this wallet knows the script corresponding to the given hash
*/
public boolean isPayToScriptHashMine(byte[] payToScriptHash) {
return findRedeemScriptFromPubHash(payToScriptHash) != null;
}
/**
* Marks all keys used in the transaction output as used in the wallet.
* See {@link com.google.bitcoin.wallet.DeterministicKeyChain#markKeyAsUsed(DeterministicKey)} for more info on this.
@@ -3405,7 +3426,8 @@ public class Wallet extends BaseTaggableObject implements Serializable, BlockCha
}
private boolean isTxOutputBloomFilterable(TransactionOutput out) {
return (out.isMine(this) && out.getScriptPubKey().isSentToRawPubKey()) ||
boolean isScriptTypeSupported = out.getScriptPubKey().isSentToRawPubKey() || out.getScriptPubKey().isPayToScriptHash();
return (out.isMine(this) && isScriptTypeSupported) ||
out.isWatched(this);
}

View File

@@ -24,7 +24,6 @@ import com.google.common.collect.Lists;
import com.google.common.primitives.UnsignedBytes;
import javax.annotation.Nullable;
import java.math.BigInteger;
import java.util.*;
import static com.google.bitcoin.script.ScriptOpCodes.*;
@@ -187,11 +186,28 @@ public class ScriptBuilder {
return new ScriptBuilder().op(OP_HASH160).data(hash).op(OP_EQUAL).build();
}
/**
* Creates a scriptPubKey for the given redeem script.
*/
public static Script createP2SHOutputScript(Script redeemScript) {
byte[] hash = Utils.sha256hash160(redeemScript.getProgram());
return ScriptBuilder.createP2SHOutputScript(hash);
}
/**
* Creates a P2SH output script with given public keys and threshold. Given public keys will be placed in
* redeem script in the lexicographical sorting order.
*/
public static Script createP2SHOutputScript(int threshold, List<ECKey> pubkeys) {
Script redeemScript = createRedeemScript(threshold, pubkeys);
return createP2SHOutputScript(redeemScript);
}
/**
* Creates redeem script with given public keys and threshold. Given public keys will be placed in
* redeem script in the lexicographical sorting order.
*/
public static Script createRedeemScript(int threshold, List<ECKey> pubkeys) {
pubkeys = new ArrayList<ECKey>(pubkeys);
final Comparator comparator = UnsignedBytes.lexicographicalComparator();
Collections.sort(pubkeys, new Comparator<ECKey>() {
@@ -200,8 +216,7 @@ public class ScriptBuilder {
return comparator.compare(k1.getPubKey(), k2.getPubKey());
}
});
Script redeemScript = ScriptBuilder.createMultiSigOutputScript(threshold, pubkeys);
byte[] hash = Utils.sha256hash160(redeemScript.getProgram());
return ScriptBuilder.createP2SHOutputScript(hash);
return ScriptBuilder.createMultiSigOutputScript(threshold, pubkeys);
}
}

View File

@@ -927,4 +927,19 @@ public class DeterministicKeyChain implements EncryptableKeyChain {
/* package */ List<ECKey> getKeys() {
return basicKeyChain.getKeys();
}
/**
* Returns leaf keys issued by this chain (not including lookahead zone)
*/
public List<DeterministicKey> getLeafKeys() {
ImmutableList.Builder<DeterministicKey> keys = ImmutableList.builder();
for (ECKey key : getKeys()) {
DeterministicKey dKey = (DeterministicKey) key;
if (dKey.getPath().size() > 2) {
keys.add(dKey);
}
}
return keys.build();
}
}

View File

@@ -21,6 +21,7 @@ import com.google.bitcoin.core.*;
import com.google.bitcoin.crypto.ChildNumber;
import com.google.bitcoin.crypto.DeterministicKey;
import com.google.bitcoin.crypto.KeyCrypter;
import com.google.bitcoin.script.Script;
import com.google.bitcoin.script.ScriptBuilder;
import com.google.bitcoin.store.UnreadableWalletException;
import com.google.bitcoin.utils.ListenerRegistration;
@@ -30,6 +31,7 @@ import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.protobuf.ByteString;
import org.bitcoinj.wallet.Protos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,6 +70,9 @@ public class KeyChainGroup {
// The map keys are the watching keys of the followed chains and values are the following chains
private Multimap<DeterministicKey, DeterministicKeyChain> followingKeychains;
// The map holds P2SH redeem scripts issued by this KeyChainGroup (including lookahead) mapped to their scriptPubKey hashes.
private LinkedHashMap<ByteString, Script> marriedKeysScripts;
private EnumMap<KeyChain.KeyPurpose, Address> currentAddresses;
@Nullable private KeyCrypter keyCrypter;
private int lookaheadSize = -1;
@@ -144,6 +149,39 @@ public class KeyChainGroup {
if (followingKeychains != null) {
this.followingKeychains.putAll(followingKeychains);
}
marriedKeysScripts = new LinkedHashMap<ByteString, Script>();
maybeLookaheadScripts();
if (!this.currentKeys.isEmpty()) {
DeterministicKey followedWatchKey = getActiveKeyChain().getWatchingKey();
for (Map.Entry<KeyChain.KeyPurpose, DeterministicKey> entry : this.currentKeys.entrySet()) {
Address address = makeP2SHOutputScript(entry.getValue(), followedWatchKey).getToAddress(params);
currentAddresses.put(entry.getKey(), address);
}
}
}
/**
* This keeps {@link #marriedKeysScripts} in sync with the number of keys issued
*/
private void maybeLookaheadScripts() {
if (chains.isEmpty())
return;
int numLeafKeys = chains.get(chains.size() - 1).getLeafKeys().size();
checkState(marriedKeysScripts.size() <= numLeafKeys, "Number of scripts is greater than number of leaf keys");
if (marriedKeysScripts.size() == numLeafKeys)
return;
for (DeterministicKeyChain chain : chains) {
if (isMarried(chain)) {
for (DeterministicKey followedKey : chain.getLeafKeys()) {
Script redeemScript = makeRedeemScript(followedKey, chain.getWatchingKey());
Script scriptPubKey = ScriptBuilder.createP2SHOutputScript(redeemScript);
marriedKeysScripts.put(ByteString.copyFrom(scriptPubKey.getPubKeyHash()), redeemScript);
}
}
}
}
private void createAndActivateNewHDChain() {
@@ -239,7 +277,9 @@ public class KeyChainGroup {
DeterministicKeyChain chain = getActiveKeyChain();
if (isMarried(chain)) {
List<ECKey> marriedKeys = freshMarriedKeys(purpose, chain);
Address freshAddress = Address.fromP2SHScript(params, ScriptBuilder.createP2SHOutputScript(2, marriedKeys));
Script p2shScript = makeP2SHOutputScript(marriedKeys);
Address freshAddress = Address.fromP2SHScript(params, p2shScript);
maybeLookaheadScripts();
currentAddresses.put(purpose, freshAddress);
return freshAddress;
} else {
@@ -259,6 +299,15 @@ public class KeyChainGroup {
return keys.build();
}
private List<ECKey> getMarriedKeysWithFollowed(DeterministicKey followedKey, Collection<DeterministicKeyChain> followingChains) {
ImmutableList.Builder<ECKey> keys = ImmutableList.builder();
for (DeterministicKeyChain keyChain : followingChains) {
keys.add(keyChain.getKeyByPath(followedKey.getPath()));
}
keys.add(followedKey);
return keys.build();
}
/** Returns the key chain that's used for generation of fresh/current keys. This is always the newest HD chain. */
public DeterministicKeyChain getActiveKeyChain() {
if (chains.isEmpty()) {
@@ -353,6 +402,15 @@ public class KeyChainGroup {
return importKeys(encryptedKeys);
}
/**
* <p>Returns redeem script for the given scriptPubKey hash.
* Returns null if no such script found
*/
@Nullable
public Script findRedeemScriptFromPubHash(byte[] payToScriptHash) {
return marriedKeysScripts.get(ByteString.copyFrom(payToScriptHash));
}
@Nullable
public ECKey findKeyFromPubHash(byte[] pubkeyHash) {
ECKey result;
@@ -513,8 +571,13 @@ public class KeyChainGroup {
public int getBloomFilterElementCount() {
int result = basic.numBloomFilterEntries();
for (DeterministicKeyChain chain : chains)
result += chain.numBloomFilterEntries();
for (DeterministicKeyChain chain : chains) {
if (isMarried(chain)) {
result += chain.getLeafKeys().size() * 2;
} else {
result += chain.numBloomFilterEntries();
}
}
return result;
}
@@ -522,8 +585,16 @@ public class KeyChainGroup {
BloomFilter filter = new BloomFilter(size, falsePositiveRate, nTweak);
if (basic.numKeys() > 0)
filter.merge(basic.getFilter(size, falsePositiveRate, nTweak));
for (DeterministicKeyChain chain : chains)
filter.merge(chain.getFilter(size, falsePositiveRate, nTweak));
for (DeterministicKeyChain chain : chains) {
if (isMarried(chain)) {
for (Map.Entry<ByteString, Script> entry : marriedKeysScripts.entrySet()) {
filter.insert(entry.getKey().toByteArray());
filter.insert(ScriptBuilder.createP2SHOutputScript(entry.getValue()).getProgram());
}
} else {
filter.merge(chain.getFilter(size, falsePositiveRate, nTweak));
}
}
return filter;
}
@@ -532,6 +603,24 @@ public class KeyChainGroup {
throw new UnsupportedOperationException(); // Unused.
}
private Script makeP2SHOutputScript(List<ECKey> marriedKeys) {
return ScriptBuilder.createP2SHOutputScript(makeRedeemScript(marriedKeys));
}
private Script makeP2SHOutputScript(DeterministicKey followedKey, DeterministicKey followedAccountKey) {
return ScriptBuilder.createP2SHOutputScript(makeRedeemScript(followedKey, followedAccountKey));
}
private Script makeRedeemScript(DeterministicKey followedKey, DeterministicKey followedAccountKey) {
Collection<DeterministicKeyChain> followingChains = followingKeychains.get(followedAccountKey);
List<ECKey> marriedKeys = getMarriedKeysWithFollowed(followedKey, followingChains);
return makeRedeemScript(marriedKeys);
}
private Script makeRedeemScript(List<ECKey> marriedKeys) {
return ScriptBuilder.createRedeemScript((marriedKeys.size() / 2) + 1, marriedKeys);
}
/** Adds a listener for events that are run when keys are added, on the user thread. */
public void addEventListener(KeyChainEventListener listener) {
addEventListener(listener, Threading.USER_THREAD);
@@ -729,12 +818,30 @@ public class KeyChainGroup {
if (watchingKey.getParent() != null) {
builder.append(String.format("Key to watch: %s%n%n", watchingKey.serializePubB58()));
}
for (ECKey key : chain.getKeys())
formatKeyWithAddress(includePrivateKeys, key, builder);
if (isMarried(chain)) {
Collection<DeterministicKeyChain> followingChains = followingKeychains.get(chain.getWatchingKey());
for (DeterministicKeyChain followingChain : followingChains) {
builder.append(String.format("Following chain: %s%n", followingChain.getWatchingKey().serializePubB58()));
}
builder.append("\n");
for (Script script : marriedKeysScripts.values())
formatScript(ScriptBuilder.createP2SHOutputScript(script), builder);
} else {
for (ECKey key : chain.getKeys())
formatKeyWithAddress(includePrivateKeys, key, builder);
}
}
return builder.toString();
}
private void formatScript(Script script, StringBuilder builder) {
builder.append(" addr:");
builder.append(script.getToAddress(params));
builder.append(" hash160:");
builder.append(Utils.HEX.encode(script.getPubKeyHash()));
builder.append("\n");
}
private void formatKeyWithAddress(boolean includePrivateKeys, ECKey key, StringBuilder builder) {
final Address address = key.toAddress(params);
builder.append(" addr:");

View File

@@ -20,6 +20,7 @@ package com.google.bitcoin.core;
import com.google.bitcoin.core.Transaction.SigHash;
import com.google.bitcoin.core.Wallet.SendRequest;
import com.google.bitcoin.crypto.*;
import com.google.bitcoin.store.MemoryBlockStore;
import com.google.bitcoin.store.WalletProtobufSerializer;
import com.google.bitcoin.testing.FakeTxBuilder;
import com.google.bitcoin.testing.MockTransactionBroadcaster;
@@ -1184,6 +1185,29 @@ public class WalletTest extends TestWithWallet {
assertTrue(wallet.getBloomFilter(1e-12).contains(outPoint.bitcoinSerialize()));
}
@Test
public void marriedKeychainBloomFilter() throws Exception {
wallet = new Wallet(params);
blockStore = new MemoryBlockStore(params);
chain = new BlockChain(params, wallet, blockStore);
String XPUB = "xpub68KFnj3bqUx1s7mHejLDBPywCAKdJEu1b49uniEEn2WSbHmZ7xbLqFTjJbtx1LUcAt1DwhoqWHmo2s5WMJp6wi38CiF2hYD49qVViKVvAoi";
wallet.addFollowingAccountKeys(ImmutableList.of(DeterministicKey.deserializeB58(null, XPUB)));
Address address = wallet.currentReceiveAddress();
assertTrue(wallet.getBloomFilter(0.001).contains(address.getHash160()));
Transaction t1 = createFakeTx(params, CENT, address);
StoredBlock b1 = createFakeBlock(blockStore, t1).storedBlock;
TransactionOutPoint outPoint = new TransactionOutPoint(params, 0, t1);
assertFalse(wallet.getBloomFilter(0.001).contains(outPoint.bitcoinSerialize()));
wallet.receiveFromBlock(t1, b1, BlockChain.NewBlockType.BEST_CHAIN, 0);
assertTrue(wallet.getBloomFilter(0.001).contains(outPoint.bitcoinSerialize()));
}
@Test
public void autosaveImmediate() throws Exception {
// Test that the wallet will save itself automatically when it changes.

View File

@@ -56,7 +56,7 @@ public class KeyChainGroupTest {
}
private KeyChainGroup createMarriedKeyChainGroup() {
byte[] seedBytes = Sha256Hash.create("don't use a string seed like this in real life".getBytes()).getBytes();
byte[] seedBytes = Sha256Hash.create("don't use a seed like this in real life".getBytes()).getBytes();
DeterministicSeed seed = new DeterministicSeed(seedBytes, MnemonicCode.BIP39_STANDARDISATION_TIME_SECS);
KeyChainGroup group = new KeyChainGroup(params, seed, ImmutableList.of(watchingAccountKey));
group.setLookaheadSize(LOOKAHEAD_SIZE);
@@ -300,6 +300,44 @@ public class KeyChainGroupTest {
assertTrue(filter.contains(key2.getPubKey()));
}
@Test
public void findRedeemScriptFromPubHash() throws Exception {
group = createMarriedKeyChainGroup();
Address address = group.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertTrue(group.findRedeemScriptFromPubHash(address.getHash160()) != null);
KeyChainGroup group2 = createMarriedKeyChainGroup();
group2.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
// test address from lookahead zone
for (int i = 0; i < LOOKAHEAD_SIZE; i++) {
address = group.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertTrue(group2.findRedeemScriptFromPubHash(address.getHash160()) != null);
}
assertFalse(group2.findRedeemScriptFromPubHash(group.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS).getHash160()) != null);
}
@Test
public void bloomFilterForMarriedChains() throws Exception {
group = createMarriedKeyChainGroup();
// only leaf keys are used for populating bloom filter, so initial number is zero
assertEquals(0, group.getBloomFilterElementCount());
Address address1 = group.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
final int size = (LOOKAHEAD_SIZE + 1 /* for the just created key */) * 2;
assertEquals(size, group.getBloomFilterElementCount());
BloomFilter filter = group.getBloomFilter(size, 0.001, (long)(Math.random() * Long.MAX_VALUE));
assertTrue(filter.contains(address1.getHash160()));
Address address2 = group.freshAddress(KeyChain.KeyPurpose.CHANGE);
assertFalse(filter.contains(address2.getHash160()));
// Check that the filter contains the lookahead buffer.
for (int i = 0; i < LOOKAHEAD_SIZE; i++) {
Address address = group.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertTrue(filter.contains(address.getHash160()));
}
// We ran ahead of the lookahead buffer.
assertFalse(filter.contains(group.freshAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS).getHash160()));
}
@Test
public void earliestKeyTime() throws Exception {
long now = Utils.currentTimeSeconds(); // mock
@@ -391,17 +429,14 @@ public class KeyChainGroupTest {
@Test
public void serializeMarried() throws Exception {
group = createMarriedKeyChainGroup();
DeterministicKeyChain keyChain = group.getActiveKeyChain();
keyChain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
DeterministicKey key1 = keyChain.getKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
ImmutableList<ChildNumber> path = key1.getPath();
assertTrue(group.isMarried(keyChain));
Address address1 = group.currentAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertTrue(group.isMarried());
List<Protos.Key> protoKeys3 = group.serializeToProtobuf();
group = KeyChainGroup.fromProtobufUnencrypted(params, protoKeys3);
assertTrue(group.isMarried(keyChain));
DeterministicKey key2 = keyChain.getKeyByPath(path);
assertEquals(key1, key2);
List<Protos.Key> protoKeys = group.serializeToProtobuf();
KeyChainGroup group2 = KeyChainGroup.fromProtobufUnencrypted(params, protoKeys);
assertTrue(group2.isMarried());
Address address2 = group2.currentAddress(KeyChain.KeyPurpose.RECEIVE_FUNDS);
assertEquals(address1, address2);
}
@Test