diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index 5ad95344..3296c3ca 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -265,4 +265,43 @@ public class CrossChainLitecoinResource { return CrossChainUtils.buildServerConfigurationInfo(Litecoin.getInstance()); } -} + + @POST + @Path("/repair") + @Operation( + summary = "Sends all coins in wallet to primary receive address", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String repairOldWallet(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String key58) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return litecoin.repairOldWallet(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index d52043bb..b7614d1e 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -505,7 +505,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { List candidates = this.getSpendingCandidateAddresses(key58); - for(DeterministicKey key : getWalletKeys(key58)) { + for(DeterministicKey key : getOldWalletKeys(key58)) { infos.add(buildAddressInfo(key, candidates)); } @@ -592,11 +592,23 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } - private List getWalletKeys(String key58) throws ForeignBlockchainException { + /** + * Get Old Wallet Keys + * + * Get wallet keys using the old key generation algorithm. This is used for diagnosing and repairing wallets + * created before 2024. + * + * @param masterPrivateKey + * + * @return the keys + * + * @throws ForeignBlockchainException + */ + private List getOldWalletKeys(String masterPrivateKey) throws ForeignBlockchainException { synchronized (this) { Context.propagate(bitcoinjContext); - Wallet wallet = walletFromDeterministicKey58(key58); + Wallet wallet = walletFromDeterministicKey58(masterPrivateKey); DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); @@ -998,4 +1010,52 @@ public abstract class Bitcoiny implements ForeignBlockchain { return Wallet.fromWatchingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); } + /** + * Repair Wallet + * + * Repair wallets generated before 2024 by moving all the address balances to the first address. + * + * @param privateMasterKey + * + * @return the transaction Id of the spend operation that moves the balances or the exception name if an exception + * is thrown + * + * @throws ForeignBlockchainException + */ + public String repairOldWallet(String privateMasterKey) throws ForeignBlockchainException { + + // create a deterministic wallet to satisfy the bitcoinj API + Wallet wallet = Wallet.createDeterministic(this.bitcoinjContext, ScriptType.P2PKH); + + // use the blockchain resources of this instance for UTXO provision + wallet.setUTXOProvider(new BitcoinyUTXOProvider( this )); + + // import in each that is generated using the old key generation algorithm + List walletKeys = getOldWalletKeys(privateMasterKey); + + for( DeterministicKey key : walletKeys) { + wallet.importKey(ECKey.fromPrivate(key.getPrivKey())); + } + + // get the primary receive address + Address firstAddress = Address.fromKey(this.params, walletKeys.get(0), ScriptType.P2PKH); + + // send all the imported coins to the primary receive address + SendRequest sendRequest = SendRequest.emptyWallet(firstAddress); + sendRequest.feePerKb = this.getFeePerKb(); + + try { + // allow the wallet to build the send request transaction and broadcast + wallet.completeTx(sendRequest); + broadcastTransaction(sendRequest.tx); + + // return the transaction Id + return sendRequest.tx.getTxId().toString(); + } + catch( Exception e ) { + // log error and return exception name + LOGGER.error(e.getMessage(), e); + return e.getClass().getSimpleName(); + } + } } diff --git a/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java b/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java new file mode 100644 index 00000000..df596de4 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinyUTXOProvider.java @@ -0,0 +1,80 @@ +package org.qortal.crosschain; + +import org.bitcoinj.core.*; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + +import java.util.ArrayList; +import java.util.List; + +/** + * Class BitcoinyUTXOProvider + * + * Uses Bitcoiny resources for UTXO provision. + */ +public class BitcoinyUTXOProvider implements UTXOProvider { + + private Bitcoiny bitcoiny; + + public BitcoinyUTXOProvider(Bitcoiny bitcoiny) { + this.bitcoiny = bitcoiny; + } + + @Override + public List getOpenTransactionOutputs(List keys) throws UTXOProviderException { + try { + List utxos = new ArrayList<>(); + + for( ECKey key : keys) { + Address address = Address.fromKey(this.bitcoiny.params, key, Script.ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + // collection UTXO's for all confirmed unspent outputs + for (UnspentOutput output : this.bitcoiny.blockchainProvider.getUnspentOutputs(script, false)) { + utxos.add(toUTXO(output)); + } + } + return utxos; + } catch (ForeignBlockchainException e) { + throw new UTXOProviderException(e); + } + } + + /** + * Convert Unspent Output to a UTXO + * + * @param unspentOutput + * + * @return the UTXO + * + * @throws ForeignBlockchainException + */ + private UTXO toUTXO(UnspentOutput unspentOutput) throws ForeignBlockchainException { + List transactionOutputs = this.bitcoiny.getOutputs(unspentOutput.hash); + + TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index); + + return new UTXO( + Sha256Hash.wrap(unspentOutput.hash), + unspentOutput.index, + Coin.valueOf(unspentOutput.value), + unspentOutput.height, + false, + transactionOutput.getScriptPubKey() + ); + } + + @Override + public int getChainHeadHeight() throws UTXOProviderException { + try { + return this.bitcoiny.blockchainProvider.getCurrentHeight(); + } catch (ForeignBlockchainException e) { + throw new UTXOProviderException(e); + } + } + + @Override + public NetworkParameters getParams() { + return this.bitcoiny.params; + } +} \ No newline at end of file diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java index e5486bb7..35da08d3 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java @@ -99,6 +99,14 @@ public abstract class BitcoinyTests extends Common { transaction = bitcoiny.buildSpend(xprv58, recipient, amount); assertNotNull(transaction); } + @Test + public void testRepair() throws ForeignBlockchainException { + String xprv58 = getDeterministicKey58(); + + String transaction = bitcoiny.repairOldWallet(xprv58); + + assertNotNull(transaction); + } @Test public void testGetWalletBalance() throws ForeignBlockchainException {