mirror of
https://github.com/Qortal/altcoinj.git
synced 2025-02-12 10:15:52 +00:00
KeyChainGroup, Wallet: Implement the upgrade path Basic --> P2PKH --> P2WPKH.
This commit is contained in:
parent
3c73f5e8a1
commit
05efa7e69e
@ -50,7 +50,10 @@ import static com.google.common.base.Preconditions.*;
|
||||
* <p>There can be active keychains for each output script type. However this class almost entirely only works on
|
||||
* the default active keychain (see {@link #getActiveKeyChain()}). The other active keychains
|
||||
* (see {@link #getActiveKeyChain(ScriptType, long)}) are meant as fallback for if a sender doesn't understand a
|
||||
* certain new script type (e.g. P2WPKH which comes with the new Bech32 address format).</p>
|
||||
* certain new script type (e.g. P2WPKH which comes with the new Bech32 address format). Active keychains
|
||||
* share the same seed, so that upgrading the wallet
|
||||
* (see {@link #upgradeToDeterministic(ScriptType, KeyChainGroupStructure, long, KeyParameter)}) to understand
|
||||
* a new script type doesn't require a fresh backup.</p>
|
||||
*
|
||||
* <p>If a key rotation time is set, it may be necessary to add a new DeterministicKeyChain with a fresh seed
|
||||
* and also preserve the old one, so funds can be swept from the rotating keys. In this case, there may be
|
||||
@ -424,6 +427,15 @@ public class KeyChainGroup implements KeyBag {
|
||||
return chains.get(chains.size() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all active chains from the given keychain group into this keychain group.
|
||||
*/
|
||||
public final void mergeActiveKeyChains(KeyChainGroup from, long keyRotationTimeSecs) {
|
||||
checkArgument(isEncrypted() == from.isEncrypted(), "encrypted and non-encrypted keychains cannot be mixed");
|
||||
for (DeterministicKeyChain chain : from.getActiveKeyChains(keyRotationTimeSecs))
|
||||
addAndActivateHDChain(chain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current lookahead size being used for ALL deterministic key chains. See
|
||||
* {@link DeterministicKeyChain#setLookaheadSize(int)}
|
||||
@ -835,9 +847,18 @@ public class KeyChainGroup implements KeyBag {
|
||||
}
|
||||
|
||||
/**
|
||||
* If the key chain contains only random keys and no deterministic key chains, this method will create a chain
|
||||
* based on the oldest non-rotating private key (i.e. the seed is derived from the old wallet).
|
||||
* <p>This method will upgrade the wallet along the following path: {@code Basic --> P2PKH --> P2WPKH}</p>
|
||||
* <p>It won't skip any steps in that upgrade path because the user might be restoring from a backup and
|
||||
* still expects money on the P2PKH chain.</p>
|
||||
* <p>It will extract and reuse the seed from the current wallet, so that a fresh backup isn't required
|
||||
* after upgrading. If coming from a basic chain containing only random keys this means it will pick the
|
||||
* oldest non-rotating private key as a seed.</p>
|
||||
* <p>Note that for upgrading an encrypted wallet, the decryption key is needed. In future, we could skip
|
||||
* that requirement for a {@code P2PKH --> P2WPKH} upgrade and just clone the encryped seed, but currently
|
||||
* the key is needed even for that.</p>
|
||||
*
|
||||
* @param preferredScriptType desired script type for the active keychain
|
||||
* @param structure keychain group structure to derive an account path from
|
||||
* @param keyRotationTimeSecs If non-zero, UNIX time for which keys created before this are assumed to be
|
||||
* compromised or weak, those keys will not be used for deterministic upgrade.
|
||||
* @param aesKey If non-null, the encryption key the keychain is encrypted under. If the keychain is encrypted
|
||||
@ -849,66 +870,95 @@ public class KeyChainGroup implements KeyBag {
|
||||
* @throws java.lang.IllegalArgumentException if the rotation time specified excludes all keys.
|
||||
* @throws DeterministicUpgradeRequiresPassword if the key chain group is encrypted
|
||||
* and you should provide the users encryption key.
|
||||
* @return the DeterministicKeyChain that was created by the upgrade.
|
||||
*/
|
||||
public DeterministicKeyChain upgradeToDeterministic(long keyRotationTimeSecs, @Nullable KeyParameter aesKey) throws DeterministicUpgradeRequiresPassword, AllRandomKeysRotating {
|
||||
public void upgradeToDeterministic(Script.ScriptType preferredScriptType, KeyChainGroupStructure structure,
|
||||
long keyRotationTimeSecs, @Nullable KeyParameter aesKey)
|
||||
throws DeterministicUpgradeRequiresPassword, AllRandomKeysRotating {
|
||||
checkState(isSupportsDeterministicChains(), "doesn't support deterministic chains");
|
||||
checkState(basic.numKeys() > 0);
|
||||
checkNotNull(structure);
|
||||
checkArgument(keyRotationTimeSecs >= 0);
|
||||
// Subtract one because the key rotation time might have been set to the creation time of the first known good
|
||||
// key, in which case, that's the one we want to find.
|
||||
ECKey keyToUse = basic.findOldestKeyAfter(keyRotationTimeSecs - 1);
|
||||
if (keyToUse == null)
|
||||
throw new AllRandomKeysRotating();
|
||||
if (!isDeterministicUpgradeRequired(preferredScriptType, keyRotationTimeSecs))
|
||||
return; // Nothing to do.
|
||||
|
||||
if (keyToUse.isEncrypted()) {
|
||||
if (aesKey == null) {
|
||||
// We can't auto upgrade because we don't know the users password at this point. We throw an
|
||||
// exception so the calling code knows to abort the load and ask the user for their password, they can
|
||||
// then try loading the wallet again passing in the AES key.
|
||||
//
|
||||
// There are a few different approaches we could have used here, but they all suck. The most obvious
|
||||
// is to try and be as lazy as possible, running in the old random-wallet mode until the user enters
|
||||
// their password for some other reason and doing the upgrade then. But this could result in strange
|
||||
// and unexpected UI flows for the user, as well as complicating the job of wallet developers who then
|
||||
// have to support both "old" and "new" UI modes simultaneously, switching them on the fly. Given that
|
||||
// this is a one-off transition, it seems more reasonable to just ask the user for their password
|
||||
// on startup, and then the wallet app can have all the widgets for accessing seed words etc active
|
||||
// all the time.
|
||||
throw new DeterministicUpgradeRequiresPassword();
|
||||
// Basic --> P2PKH upgrade
|
||||
if (basic.numKeys() > 0 && getActiveKeyChain(Script.ScriptType.P2PKH, keyRotationTimeSecs) == null) {
|
||||
// Subtract one because the key rotation time might have been set to the creation time of the first known good
|
||||
// key, in which case, that's the one we want to find.
|
||||
ECKey keyToUse = basic.findOldestKeyAfter(keyRotationTimeSecs - 1);
|
||||
if (keyToUse == null)
|
||||
throw new AllRandomKeysRotating();
|
||||
boolean keyWasEncrypted = keyToUse.isEncrypted();
|
||||
if (keyWasEncrypted) {
|
||||
if (aesKey == null) {
|
||||
// We can't auto upgrade because we don't know the users password at this point. We throw an
|
||||
// exception so the calling code knows to abort the load and ask the user for their password, they can
|
||||
// then try loading the wallet again passing in the AES key.
|
||||
//
|
||||
// There are a few different approaches we could have used here, but they all suck. The most obvious
|
||||
// is to try and be as lazy as possible, running in the old random-wallet mode until the user enters
|
||||
// their password for some other reason and doing the upgrade then. But this could result in strange
|
||||
// and unexpected UI flows for the user, as well as complicating the job of wallet developers who then
|
||||
// have to support both "old" and "new" UI modes simultaneously, switching them on the fly. Given that
|
||||
// this is a one-off transition, it seems more reasonable to just ask the user for their password
|
||||
// on startup, and then the wallet app can have all the widgets for accessing seed words etc active
|
||||
// all the time.
|
||||
throw new DeterministicUpgradeRequiresPassword();
|
||||
}
|
||||
keyToUse = keyToUse.decrypt(aesKey);
|
||||
} else if (aesKey != null) {
|
||||
throw new IllegalStateException("AES Key was provided but wallet is not encrypted.");
|
||||
}
|
||||
keyToUse = keyToUse.decrypt(aesKey);
|
||||
} else if (aesKey != null) {
|
||||
throw new IllegalStateException("AES Key was provided but wallet is not encrypted.");
|
||||
|
||||
log.info(
|
||||
"Upgrading from basic keychain to P2PKH deterministic keychain. Using oldest non-rotating private key (address: {})",
|
||||
LegacyAddress.fromKey(params, keyToUse));
|
||||
byte[] entropy = checkNotNull(keyToUse.getSecretBytes());
|
||||
// Private keys should be at least 128 bits long.
|
||||
checkState(entropy.length >= DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8);
|
||||
// We reduce the entropy here to 128 bits because people like to write their seeds down on paper, and 128
|
||||
// bits should be sufficient forever unless the laws of the universe change or ECC is broken; in either case
|
||||
// we all have bigger problems.
|
||||
entropy = Arrays.copyOfRange(entropy, 0, DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8); // final argument is exclusive range.
|
||||
checkState(entropy.length == DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8);
|
||||
DeterministicKeyChain chain = DeterministicKeyChain.builder()
|
||||
.entropy(entropy, keyToUse.getCreationTimeSeconds())
|
||||
.outputScriptType(Script.ScriptType.P2PKH)
|
||||
.accountPath(structure.accountPathFor(Script.ScriptType.P2PKH)).build();
|
||||
if (keyWasEncrypted)
|
||||
chain = chain.toEncrypted(checkNotNull(keyCrypter), aesKey);
|
||||
addAndActivateHDChain(chain);
|
||||
}
|
||||
|
||||
if (chains.isEmpty()) {
|
||||
log.info("Auto-upgrading pre-HD wallet to HD!");
|
||||
} else {
|
||||
log.info("Wallet with existing HD chain is being re-upgraded due to change in key rotation time.");
|
||||
// P2PKH --> P2WPKH upgrade
|
||||
if (preferredScriptType == Script.ScriptType.P2WPKH
|
||||
&& getActiveKeyChain(Script.ScriptType.P2WPKH, keyRotationTimeSecs) == null) {
|
||||
DeterministicSeed seed = getActiveKeyChain(Script.ScriptType.P2PKH, keyRotationTimeSecs).getSeed();
|
||||
boolean seedWasEncrypted = seed.isEncrypted();
|
||||
if (seedWasEncrypted) {
|
||||
if (aesKey == null)
|
||||
throw new DeterministicUpgradeRequiresPassword();
|
||||
seed = seed.decrypt(keyCrypter, "", aesKey);
|
||||
}
|
||||
log.info("Upgrading from P2PKH to P2WPKH deterministic keychain. Using seed: {}", seed);
|
||||
DeterministicKeyChain chain = DeterministicKeyChain.builder().seed(seed)
|
||||
.outputScriptType(Script.ScriptType.P2WPKH)
|
||||
.accountPath(structure.accountPathFor(Script.ScriptType.P2WPKH)).build();
|
||||
if (seedWasEncrypted)
|
||||
chain = chain.toEncrypted(checkNotNull(keyCrypter), aesKey);
|
||||
addAndActivateHDChain(chain);
|
||||
}
|
||||
log.info("Instantiating new HD chain using oldest non-rotating private key (address: {})", LegacyAddress.fromKey(params, keyToUse));
|
||||
byte[] entropy = checkNotNull(keyToUse.getSecretBytes());
|
||||
// Private keys should be at least 128 bits long.
|
||||
checkState(entropy.length >= DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8);
|
||||
// We reduce the entropy here to 128 bits because people like to write their seeds down on paper, and 128
|
||||
// bits should be sufficient forever unless the laws of the universe change or ECC is broken; in either case
|
||||
// we all have bigger problems.
|
||||
entropy = Arrays.copyOfRange(entropy, 0, DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8); // final argument is exclusive range.
|
||||
checkState(entropy.length == DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8);
|
||||
String passphrase = ""; // FIXME allow non-empty passphrase
|
||||
DeterministicKeyChain chain = DeterministicKeyChain.builder()
|
||||
.entropy(entropy, keyToUse.getCreationTimeSeconds()).passphrase(passphrase).build();
|
||||
if (aesKey != null) {
|
||||
chain = chain.toEncrypted(checkNotNull(basic.getKeyCrypter()), aesKey);
|
||||
}
|
||||
chains.add(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** Returns true if the group contains random keys but no HD chains. */
|
||||
public boolean isDeterministicUpgradeRequired() {
|
||||
return basic.numKeys() > 0 && chains != null && chains.isEmpty();
|
||||
/**
|
||||
* Returns true if a call to {@link #upgradeToDeterministic(ScriptType, KeyChainGroupStructure, long, KeyParameter)} is required
|
||||
* in order to have an active deterministic keychain of the desired script type.
|
||||
*/
|
||||
public boolean isDeterministicUpgradeRequired(Script.ScriptType preferredScriptType, long keyRotationTimeSecs) {
|
||||
if (!isSupportsDeterministicChains())
|
||||
return false;
|
||||
if (getActiveKeyChain(preferredScriptType, keyRotationTimeSecs) == null)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static EnumMap<KeyChain.KeyPurpose, DeterministicKey> createCurrentKeysMap(List<DeterministicKeyChain> chains) {
|
||||
|
@ -741,10 +741,24 @@ public class Wallet extends BaseTaggableObject
|
||||
* to do this will result in an exception being thrown. For non-encrypted wallets, the upgrade will be done for
|
||||
* you automatically the first time a new key is requested (this happens when spending due to the change address).
|
||||
*/
|
||||
public void upgradeToDeterministic(@Nullable KeyParameter aesKey) throws DeterministicUpgradeRequiresPassword {
|
||||
public void upgradeToDeterministic(Script.ScriptType outputScriptType, @Nullable KeyParameter aesKey)
|
||||
throws DeterministicUpgradeRequiresPassword {
|
||||
upgradeToDeterministic(outputScriptType, KeyChainGroupStructure.DEFAULT, aesKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades the wallet to be deterministic (BIP32). You should call this, possibly providing the users encryption
|
||||
* key, after loading a wallet produced by previous versions of bitcoinj. If the wallet is encrypted the key
|
||||
* <b>must</b> be provided, due to the way the seed is derived deterministically from private key bytes: failing
|
||||
* to do this will result in an exception being thrown. For non-encrypted wallets, the upgrade will be done for
|
||||
* you automatically the first time a new key is requested (this happens when spending due to the change address).
|
||||
*/
|
||||
public void upgradeToDeterministic(Script.ScriptType outputScriptType, KeyChainGroupStructure structure,
|
||||
@Nullable KeyParameter aesKey) throws DeterministicUpgradeRequiresPassword {
|
||||
keyChainGroupLock.lock();
|
||||
try {
|
||||
keyChainGroup.upgradeToDeterministic(vKeyRotationTimestamp, aesKey);
|
||||
long keyRotationTimeSecs = vKeyRotationTimestamp;
|
||||
keyChainGroup.upgradeToDeterministic(outputScriptType, structure, keyRotationTimeSecs, aesKey);
|
||||
} finally {
|
||||
keyChainGroupLock.unlock();
|
||||
}
|
||||
@ -752,13 +766,14 @@ public class Wallet extends BaseTaggableObject
|
||||
|
||||
/**
|
||||
* Returns true if the wallet contains random keys and no HD chains, in which case you should call
|
||||
* {@link #upgradeToDeterministic(KeyParameter)} before attempting to do anything
|
||||
* {@link #upgradeToDeterministic(ScriptType, KeyParameter)} before attempting to do anything
|
||||
* that would require a new address or key.
|
||||
*/
|
||||
public boolean isDeterministicUpgradeRequired() {
|
||||
public boolean isDeterministicUpgradeRequired(Script.ScriptType outputScriptType) {
|
||||
keyChainGroupLock.lock();
|
||||
try {
|
||||
return keyChainGroup.isDeterministicUpgradeRequired();
|
||||
long keyRotationTimeSecs = vKeyRotationTimestamp;
|
||||
return keyChainGroup.isDeterministicUpgradeRequired(outputScriptType, keyRotationTimeSecs);
|
||||
} finally {
|
||||
keyChainGroupLock.unlock();
|
||||
}
|
||||
@ -5218,18 +5233,39 @@ public class Wallet extends BaseTaggableObject
|
||||
* transactions. Maintenance might also include internal changes that involve some processing or work but
|
||||
* which don't require making transactions - these will happen automatically unless the password is required
|
||||
* in which case an exception will be thrown.
|
||||
*
|
||||
* @param aesKey the users password, if any.
|
||||
* @param signAndSend if true, send the transactions via the tx broadcaster and return them, if false just return them.
|
||||
*
|
||||
* @return A list of transactions that the wallet just made/will make for internal maintenance. Might be empty.
|
||||
* @throws org.bitcoinj.wallet.DeterministicUpgradeRequiresPassword if key rotation requires the users password.
|
||||
*/
|
||||
public ListenableFuture<List<Transaction>> doMaintenance(@Nullable KeyParameter aesKey, boolean signAndSend) throws DeterministicUpgradeRequiresPassword {
|
||||
public ListenableFuture<List<Transaction>> doMaintenance(@Nullable KeyParameter aesKey, boolean signAndSend)
|
||||
throws DeterministicUpgradeRequiresPassword {
|
||||
return doMaintenance(KeyChainGroupStructure.DEFAULT, aesKey, signAndSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wallet app should call this from time to time in order to let the wallet craft and send transactions needed
|
||||
* to re-organise coins internally. A good time to call this would be after receiving coins for an unencrypted
|
||||
* wallet, or after sending money for an encrypted wallet. If you have an encrypted wallet and just want to know
|
||||
* if some maintenance needs doing, call this method with andSend set to false and look at the returned list of
|
||||
* transactions. Maintenance might also include internal changes that involve some processing or work but
|
||||
* which don't require making transactions - these will happen automatically unless the password is required
|
||||
* in which case an exception will be thrown.
|
||||
* @param structure to derive the account path from if a new seed needs to be created
|
||||
* @param aesKey the users password, if any.
|
||||
* @param signAndSend if true, send the transactions via the tx broadcaster and return them, if false just return them.
|
||||
*
|
||||
* @return A list of transactions that the wallet just made/will make for internal maintenance. Might be empty.
|
||||
* @throws org.bitcoinj.wallet.DeterministicUpgradeRequiresPassword if key rotation requires the users password.
|
||||
*/
|
||||
public ListenableFuture<List<Transaction>> doMaintenance(KeyChainGroupStructure structure,
|
||||
@Nullable KeyParameter aesKey, boolean signAndSend) throws DeterministicUpgradeRequiresPassword {
|
||||
List<Transaction> txns;
|
||||
lock.lock();
|
||||
keyChainGroupLock.lock();
|
||||
try {
|
||||
txns = maybeRotateKeys(aesKey, signAndSend);
|
||||
txns = maybeRotateKeys(structure, aesKey, signAndSend);
|
||||
if (!signAndSend)
|
||||
return Futures.immediateFuture(txns);
|
||||
} finally {
|
||||
@ -5263,7 +5299,8 @@ public class Wallet extends BaseTaggableObject
|
||||
|
||||
// Checks to see if any coins are controlled by rotating keys and if so, spends them.
|
||||
@GuardedBy("keyChainGroupLock")
|
||||
private List<Transaction> maybeRotateKeys(@Nullable KeyParameter aesKey, boolean sign) throws DeterministicUpgradeRequiresPassword {
|
||||
private List<Transaction> maybeRotateKeys(KeyChainGroupStructure structure, @Nullable KeyParameter aesKey,
|
||||
boolean sign) throws DeterministicUpgradeRequiresPassword {
|
||||
checkState(lock.isHeldByCurrentThread());
|
||||
checkState(keyChainGroupLock.isHeldByCurrentThread());
|
||||
List<Transaction> results = Lists.newLinkedList();
|
||||
@ -5273,27 +5310,50 @@ public class Wallet extends BaseTaggableObject
|
||||
|
||||
// We might have to create a new HD hierarchy if the previous ones are now rotating.
|
||||
boolean allChainsRotating = true;
|
||||
for (DeterministicKeyChain chain : keyChainGroup.getDeterministicKeyChains()) {
|
||||
if (chain.getEarliestKeyCreationTime() >= keyRotationTimestamp) {
|
||||
allChainsRotating = false;
|
||||
break;
|
||||
Script.ScriptType preferredScriptType = Script.ScriptType.P2PKH;
|
||||
if (keyChainGroup.isSupportsDeterministicChains()) {
|
||||
for (DeterministicKeyChain chain : keyChainGroup.getDeterministicKeyChains()) {
|
||||
if (chain.getEarliestKeyCreationTime() >= keyRotationTimestamp) {
|
||||
allChainsRotating = false;
|
||||
preferredScriptType = chain.getOutputScriptType();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allChainsRotating) {
|
||||
try {
|
||||
if (keyChainGroup.getImportedKeys().isEmpty()) {
|
||||
log.info("All HD chains are currently rotating and we have no random keys, creating fresh HD chain ...");
|
||||
DeterministicKeyChain chain = DeterministicKeyChain.builder().random(new SecureRandom()).build();
|
||||
keyChainGroup.addAndActivateHDChain(chain);
|
||||
KeyChainGroup newChains = KeyChainGroup.builder(params, structure).fromRandom(preferredScriptType)
|
||||
.build();
|
||||
if (keyChainGroup.isEncrypted()) {
|
||||
if (aesKey == null)
|
||||
throw new DeterministicUpgradeRequiresPassword();
|
||||
KeyCrypter keyCrypter = keyChainGroup.getKeyCrypter();
|
||||
keyChainGroup.decrypt(aesKey);
|
||||
keyChainGroup.mergeActiveKeyChains(newChains, keyRotationTimestamp);
|
||||
keyChainGroup.encrypt(keyCrypter, aesKey);
|
||||
} else {
|
||||
keyChainGroup.mergeActiveKeyChains(newChains, keyRotationTimestamp);
|
||||
}
|
||||
} else {
|
||||
log.info("All HD chains are currently rotating, attempting to create a new one from the next oldest non-rotating key material ...");
|
||||
keyChainGroup.upgradeToDeterministic(keyRotationTimestamp, aesKey);
|
||||
keyChainGroup.upgradeToDeterministic(preferredScriptType, structure, keyRotationTimestamp, aesKey);
|
||||
log.info(" ... upgraded to HD again, based on next best oldest key.");
|
||||
}
|
||||
} catch (AllRandomKeysRotating rotating) {
|
||||
log.info(" ... no non-rotating random keys available, generating entirely new HD tree: backup required after this.");
|
||||
DeterministicKeyChain chain = DeterministicKeyChain.builder().random(new SecureRandom()).build();
|
||||
keyChainGroup.addAndActivateHDChain(chain);
|
||||
KeyChainGroup newChains = KeyChainGroup.builder(params, structure).fromRandom(preferredScriptType)
|
||||
.build();
|
||||
if (keyChainGroup.isEncrypted()) {
|
||||
if (aesKey == null)
|
||||
throw new DeterministicUpgradeRequiresPassword();
|
||||
KeyCrypter keyCrypter = keyChainGroup.getKeyCrypter();
|
||||
keyChainGroup.decrypt(aesKey);
|
||||
keyChainGroup.mergeActiveKeyChains(newChains, keyRotationTimestamp);
|
||||
keyChainGroup.encrypt(keyCrypter, aesKey);
|
||||
} else {
|
||||
keyChainGroup.mergeActiveKeyChains(newChains, keyRotationTimestamp);
|
||||
}
|
||||
}
|
||||
saveNow();
|
||||
}
|
||||
|
@ -523,7 +523,8 @@ public class KeyChainGroupTest {
|
||||
// Check that if we try to use HD features in a KCG that only has random keys, we get an exception.
|
||||
group = KeyChainGroup.builder(MAINNET).build();
|
||||
group.importKeys(new ECKey(), new ECKey());
|
||||
assertTrue(group.isDeterministicUpgradeRequired());
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH, 0));
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH, 0));
|
||||
group.freshKey(KeyChain.KeyPurpose.RECEIVE_FUNDS); // throws
|
||||
}
|
||||
|
||||
@ -538,14 +539,19 @@ public class KeyChainGroupTest {
|
||||
group.importKeys(key2, key1);
|
||||
|
||||
List<Protos.Key> protobufs = group.serializeToProtobuf();
|
||||
group.upgradeToDeterministic(0, null);
|
||||
assertFalse(group.isDeterministicUpgradeRequired());
|
||||
group.upgradeToDeterministic(Script.ScriptType.P2PKH, KeyChainGroupStructure.DEFAULT, 0, null);
|
||||
assertFalse(group.isEncrypted());
|
||||
assertFalse(group.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH, 0));
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH, 0));
|
||||
DeterministicKey dkey1 = group.freshKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
|
||||
DeterministicSeed seed1 = group.getActiveKeyChain().getSeed();
|
||||
assertNotNull(seed1);
|
||||
|
||||
group = KeyChainGroup.fromProtobufUnencrypted(MAINNET, protobufs);
|
||||
group.upgradeToDeterministic(0, null); // Should give same result as last time.
|
||||
group.upgradeToDeterministic(Script.ScriptType.P2PKH, KeyChainGroupStructure.DEFAULT, 0, null); // Should give same result as last time.
|
||||
assertFalse(group.isEncrypted());
|
||||
assertFalse(group.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH, 0));
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH, 0));
|
||||
DeterministicKey dkey2 = group.freshKey(KeyChain.KeyPurpose.RECEIVE_FUNDS);
|
||||
DeterministicSeed seed2 = group.getActiveKeyChain().getSeed();
|
||||
assertEquals(seed1, seed2);
|
||||
@ -566,7 +572,7 @@ public class KeyChainGroupTest {
|
||||
Utils.rollMockClock(86400);
|
||||
ECKey key3 = new ECKey();
|
||||
group.importKeys(key2, key1, key3);
|
||||
group.upgradeToDeterministic(now + 10, null);
|
||||
group.upgradeToDeterministic(Script.ScriptType.P2PKH, KeyChainGroupStructure.DEFAULT, now + 10, null);
|
||||
DeterministicSeed seed = group.getActiveKeyChain().getSeed();
|
||||
assertNotNull(seed);
|
||||
// Check we used the right key: oldest non rotating.
|
||||
@ -581,17 +587,19 @@ public class KeyChainGroupTest {
|
||||
group.importKeys(key);
|
||||
final KeyCrypterScrypt crypter = new KeyCrypterScrypt();
|
||||
final KeyParameter aesKey = crypter.deriveKey("abc");
|
||||
assertTrue(group.isDeterministicUpgradeRequired());
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH, 0));
|
||||
group.encrypt(crypter, aesKey);
|
||||
assertTrue(group.isDeterministicUpgradeRequired());
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH, 0));
|
||||
try {
|
||||
group.upgradeToDeterministic(0, null);
|
||||
group.upgradeToDeterministic(Script.ScriptType.P2PKH, KeyChainGroupStructure.DEFAULT, 0, null);
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiresPassword e) {
|
||||
// Expected.
|
||||
}
|
||||
group.upgradeToDeterministic(0, aesKey);
|
||||
assertFalse(group.isDeterministicUpgradeRequired());
|
||||
group.upgradeToDeterministic(Script.ScriptType.P2PKH, KeyChainGroupStructure.DEFAULT, 0, aesKey);
|
||||
assertTrue(group.isEncrypted());
|
||||
assertFalse(group.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH, 0));
|
||||
assertTrue(group.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH, 0));
|
||||
final DeterministicSeed deterministicSeed = group.getActiveKeyChain().getSeed();
|
||||
assertNotNull(deterministicSeed);
|
||||
assertTrue(deterministicSeed.isEncrypted());
|
||||
|
@ -3051,7 +3051,7 @@ public class WalletTest extends TestWithWallet {
|
||||
Utils.rollMockClock(86400);
|
||||
wallet = new Wallet(UNITTEST, kcg); // This avoids the automatic HD initialisation
|
||||
assertTrue(kcg.getDeterministicKeyChains().isEmpty());
|
||||
wallet.upgradeToDeterministic(null);
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2PKH, null);
|
||||
DeterministicKey badWatchingKey = wallet.getWatchingKey();
|
||||
assertEquals(badKey.getCreationTimeSeconds(), badWatchingKey.getCreationTimeSeconds());
|
||||
sendMoneyToWallet(wallet, AbstractBlockChain.NewBlockType.BEST_CHAIN, CENT, LegacyAddress.fromKey(UNITTEST, badWatchingKey));
|
||||
@ -3292,45 +3292,166 @@ public class WalletTest extends TestWithWallet {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_unencrypted() throws Exception {
|
||||
// This isn't very deep because most of it is tested in KeyChainGroupTest and Wallet just forwards most logic
|
||||
// there.
|
||||
// Create an old-style random wallet.
|
||||
wallet = Wallet.fromKeys(UNITTEST, Arrays.asList(new ECKey(), new ECKey()));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired());
|
||||
public void upgradeToDeterministic_basic_to_P2PKH_unencrypted() throws Exception {
|
||||
wallet = new Wallet(UNITTEST, KeyChainGroup.builder(UNITTEST).build());
|
||||
wallet.importKeys(Arrays.asList(new ECKey(), new ECKey()));
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
try {
|
||||
wallet.freshReceiveKey();
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiredException e) {
|
||||
// Expected.
|
||||
}
|
||||
wallet.upgradeToDeterministic(null);
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired());
|
||||
// Use an HD feature.
|
||||
wallet.freshReceiveKey(); // works.
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2PKH, null);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_encrypted() throws Exception {
|
||||
// Create an old-style random wallet.
|
||||
wallet = Wallet.fromKeys(UNITTEST, Arrays.asList(new ECKey(), new ECKey()));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired());
|
||||
KeyCrypter crypter = new KeyCrypterScrypt();
|
||||
KeyParameter aesKey = crypter.deriveKey("abc");
|
||||
wallet.encrypt(crypter, aesKey);
|
||||
public void upgradeToDeterministic_basic_to_P2PKH_encrypted() throws Exception {
|
||||
wallet = new Wallet(UNITTEST, KeyChainGroup.builder(UNITTEST).build());
|
||||
wallet.importKeys(Arrays.asList(new ECKey(), new ECKey()));
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
|
||||
KeyParameter aesKey = new KeyCrypterScrypt().deriveKey("abc");
|
||||
wallet.encrypt(new KeyCrypterScrypt(), aesKey);
|
||||
assertTrue(wallet.isEncrypted());
|
||||
try {
|
||||
wallet.freshReceiveKey();
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiredException e) {
|
||||
// Expected.
|
||||
}
|
||||
try {
|
||||
wallet.upgradeToDeterministic(null);
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2PKH, null);
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiresPassword e) {
|
||||
// Expected.
|
||||
}
|
||||
wallet.upgradeToDeterministic(aesKey);
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired());
|
||||
// Use an HD feature.
|
||||
wallet.freshReceiveKey(); // works.
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2PKH, aesKey);
|
||||
assertTrue(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_basic_to_P2WPKH_unencrypted() throws Exception {
|
||||
wallet = new Wallet(UNITTEST, KeyChainGroup.builder(UNITTEST).build());
|
||||
wallet.importKeys(Arrays.asList(new ECKey(), new ECKey()));
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
try {
|
||||
wallet.freshReceiveKey();
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiredException e) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2WPKH, null);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_basic_to_P2WPKH_encrypted() throws Exception {
|
||||
wallet = new Wallet(UNITTEST, KeyChainGroup.builder(UNITTEST).build());
|
||||
wallet.importKeys(Arrays.asList(new ECKey(), new ECKey()));
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
|
||||
KeyParameter aesKey = new KeyCrypterScrypt().deriveKey("abc");
|
||||
wallet.encrypt(new KeyCrypterScrypt(), aesKey);
|
||||
assertTrue(wallet.isEncrypted());
|
||||
try {
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2WPKH, null);
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiresPassword e) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2WPKH, aesKey);
|
||||
assertTrue(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_P2PKH_to_P2WPKH_unencrypted() throws Exception {
|
||||
wallet = Wallet.createDeterministic(UNITTEST, Script.ScriptType.P2PKH);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2WPKH, null);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_P2PKH_to_P2WPKH_encrypted() throws Exception {
|
||||
wallet = Wallet.createDeterministic(UNITTEST, Script.ScriptType.P2PKH);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertTrue(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
|
||||
KeyParameter aesKey = new KeyCrypterScrypt().deriveKey("abc");
|
||||
wallet.encrypt(new KeyCrypterScrypt(), aesKey);
|
||||
assertTrue(wallet.isEncrypted());
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2PKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
try {
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2WPKH, null);
|
||||
fail();
|
||||
} catch (DeterministicUpgradeRequiresPassword e) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2WPKH, aesKey);
|
||||
assertTrue(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upgradeToDeterministic_noDowngrade_unencrypted() throws Exception {
|
||||
wallet = Wallet.createDeterministic(UNITTEST, Script.ScriptType.P2WPKH);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2PKH, null);
|
||||
assertFalse(wallet.isEncrypted());
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2PKH));
|
||||
assertFalse(wallet.isDeterministicUpgradeRequired(Script.ScriptType.P2WPKH));
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.currentReceiveAddress().getOutputScriptType());
|
||||
assertEquals(Script.ScriptType.P2WPKH, wallet.freshReceiveAddress().getOutputScriptType());
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
@ -3425,7 +3546,7 @@ public class WalletTest extends TestWithWallet {
|
||||
Wallet wallet = Wallet.fromKeys(UNITTEST, Arrays.asList(key));
|
||||
assertEquals(1, wallet.getImportedKeys().size());
|
||||
assertEquals(key, wallet.getImportedKeys().get(0));
|
||||
wallet.upgradeToDeterministic(null);
|
||||
wallet.upgradeToDeterministic(Script.ScriptType.P2PKH, null);
|
||||
String seed = wallet.getKeyChainSeed().toHexString();
|
||||
assertEquals("5ca8cd6c01aa004d3c5396c628b78a4a89462f412f460a845b594ac42eceaa264b0e14dcd4fe73d4ed08ce06f4c28facfa85042d26d784ab2798a870bb7af556", seed);
|
||||
}
|
||||
|
@ -211,6 +211,7 @@ public class WalletTool {
|
||||
ENCRYPT,
|
||||
DECRYPT,
|
||||
MARRY,
|
||||
UPGRADE,
|
||||
ROTATE,
|
||||
SET_CREATION_TIME,
|
||||
}
|
||||
@ -235,7 +236,8 @@ public class WalletTool {
|
||||
OptionSpec<String> walletFileName = parser.accepts("wallet").withRequiredArg().defaultsTo("wallet");
|
||||
seedFlag = parser.accepts("seed").withRequiredArg();
|
||||
watchFlag = parser.accepts("watchkey").withRequiredArg();
|
||||
outputScriptTypeFlag = parser.accepts("output-script-type").withRequiredArg().ofType(Script.ScriptType.class);
|
||||
outputScriptTypeFlag = parser.accepts("output-script-type").withRequiredArg().ofType(Script.ScriptType.class)
|
||||
.defaultsTo(Script.ScriptType.P2PKH);
|
||||
OptionSpec<NetworkEnum> netFlag = parser.accepts("net").withRequiredArg().ofType(NetworkEnum.class).defaultsTo(NetworkEnum.MAIN);
|
||||
dateFlag = parser.accepts("date").withRequiredArg().ofType(Date.class)
|
||||
.withValuesConvertedBy(DateConverter.datePattern("yyyy/MM/dd"));
|
||||
@ -480,6 +482,7 @@ public class WalletTool {
|
||||
case ENCRYPT: encrypt(); break;
|
||||
case DECRYPT: decrypt(); break;
|
||||
case MARRY: marry(); break;
|
||||
case UPGRADE: upgrade(); break;
|
||||
case ROTATE: rotate(); break;
|
||||
case SET_CREATION_TIME: setCreationTime(); break;
|
||||
}
|
||||
@ -555,6 +558,27 @@ public class WalletTool {
|
||||
wallet.addAndActivateHDChain(chain);
|
||||
}
|
||||
|
||||
private static void upgrade() {
|
||||
DeterministicKeyChain activeKeyChain = wallet.getActiveKeyChain();
|
||||
ScriptType currentOutputScriptType = activeKeyChain != null ? activeKeyChain.getOutputScriptType() : null;
|
||||
ScriptType outputScriptType = options.valueOf(outputScriptTypeFlag);
|
||||
if (!wallet.isDeterministicUpgradeRequired(outputScriptType)) {
|
||||
System.err
|
||||
.println("No upgrade from " + (currentOutputScriptType != null ? currentOutputScriptType : "basic")
|
||||
+ " to " + outputScriptType);
|
||||
return;
|
||||
}
|
||||
KeyParameter aesKey = null;
|
||||
if (wallet.isEncrypted()) {
|
||||
aesKey = passwordToKey(true);
|
||||
if (aesKey == null)
|
||||
return;
|
||||
}
|
||||
wallet.upgradeToDeterministic(outputScriptType, aesKey);
|
||||
System.out.println("Upgraded from " + (currentOutputScriptType != null ? currentOutputScriptType : "basic")
|
||||
+ " to " + outputScriptType);
|
||||
}
|
||||
|
||||
private static void rotate() throws BlockStoreException {
|
||||
setup();
|
||||
peerGroup.start();
|
||||
|
@ -13,7 +13,7 @@ Usage: wallet-tool --flags action-name
|
||||
If --seed is present, it should specify either a mnemonic code or hex/base58 raw seed bytes.
|
||||
If --watchkey is present, it creates a watching wallet using the specified base58 xpub.
|
||||
If --seed or --watchkey is combined with either --date or --unixtime, use that as a birthdate for.
|
||||
If --output-script-type, use that for deriving addresses.
|
||||
If --output-script-type is present, use that for deriving addresses.
|
||||
the wallet. See the set-creation-time action for the meaning of these flags.
|
||||
marry Makes the wallet married with other parties, requiring multisig to spend funds.
|
||||
External public keys for other signing parties must be specified with --xpubkeys (comma separated).
|
||||
@ -57,6 +57,8 @@ Usage: wallet-tool --flags action-name
|
||||
--no-pki disables pki verification for payment requests.
|
||||
encrypt Requires --password and uses it to encrypt the wallet in place.
|
||||
decrypt Requires --password and uses it to decrypt the wallet in place.
|
||||
upgrade Upgrade basic or deterministic wallets to deterministic wallets of the given script type.
|
||||
If --output-script-type is present, use that as the upgrade target.
|
||||
rotate Takes --date and sets that as the key rotation time. Any coins controlled by keys or HD chains
|
||||
created before this date will be re-spent to a key (from an HD tree) that was created after it.
|
||||
If --date is missing, the current time is assumed. If the time covers all keys, a new HD tree
|
||||
@ -100,6 +102,7 @@ Usage: wallet-tool --flags action-name
|
||||
--chain=<file> Specifies the name of the file that stores the block chain.
|
||||
--force Overrides any safety checks on the requested action.
|
||||
--date Provide a date in form YYYY/MM/DD to any action that requires one.
|
||||
--output-script-type Provide an output script type to any action that requires one. May be P2PKH or P2WPKH.
|
||||
--peers=1.2.3.4 Comma separated IP addresses/domain names for connections instead of peer discovery.
|
||||
--offline If specified when sending, don't try and connect, just write the tx to the wallet.
|
||||
--condition=... Allows you to specify a numeric condition for other commands. The format is
|
||||
|
Loading…
x
Reference in New Issue
Block a user