From 383c5277d771ee5de4f606f3d67ee71dedd87967 Mon Sep 17 00:00:00 2001 From: chyngyz Date: Mon, 26 Feb 2024 16:56:45 +0600 Subject: [PATCH 1/6] Initial implementation of RBF --- .../bitcoincore/AbstractKit.kt | 20 ++ .../bitcoincore/BitcoinCore.kt | 30 ++ .../bitcoincore/BitcoinCoreBuilder.kt | 13 +- .../core/BaseTransactionInfoConverter.kt | 5 +- .../bitcoincore/core/DataProvider.kt | 5 + .../bitcoincore/core/Interfaces.kt | 1 + .../managers/UnspentOutputProvider.kt | 30 +- .../bitcoincore/models/TransactionInfo.kt | 32 +- .../bitcoincore/models/TransactionInput.kt | 7 +- .../bitcoincore/models/TransactionOutput.kt | 16 + .../bitcoincore/rbf/ReplacementTransaction.kt | 10 + .../rbf/ReplacementTransactionBuilder.kt | 313 ++++++++++++++++++ .../bitcoincore/rbf/ReplacementType.kt | 8 + .../bitcoincore/storage/CoreDatabase.kt | 3 +- .../bitcoincore/storage/DataObjects.kt | 24 +- .../bitcoincore/storage/Storage.kt | 16 +- .../storage/TransactionInputDao.kt | 34 +- .../storage/migrations/Migration_17_18.kt | 17 + .../transactions/TransactionCreator.kt | 46 ++- .../transactions/TransactionInvalidator.kt | 16 +- .../transactions/TransactionSizeCalculator.kt | 23 +- .../builder/TransactionBuilder.kt | 11 +- 22 files changed, 583 insertions(+), 97 deletions(-) create mode 100644 bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt create mode 100644 bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt create mode 100644 bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt create mode 100644 bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/migrations/Migration_17_18.kt diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt index 0b3066d30..530e987d5 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt @@ -9,6 +9,8 @@ import io.horizontalsystems.bitcoincore.models.TransactionFilterType import io.horizontalsystems.bitcoincore.models.TransactionInfo import io.horizontalsystems.bitcoincore.models.UsedAddress import io.horizontalsystems.bitcoincore.network.Network +import io.horizontalsystems.bitcoincore.rbf.ReplacementTransaction +import io.horizontalsystems.bitcoincore.rbf.ReplacementType import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput import io.horizontalsystems.bitcoincore.storage.UnspentOutputInfo @@ -188,4 +190,22 @@ abstract class AbstractKit { fun getRawTransaction(transactionHash: String): String? { return bitcoinCore.getRawTransaction(transactionHash) } + + fun speedUpTransaction(transactionHash: String, minFee: Long): ReplacementTransaction { + return bitcoinCore.replacementTransaction(transactionHash, minFee, ReplacementType.SpeedUp) + } + + fun cancelTransaction(transactionHash: String, minFee: Long): ReplacementTransaction { + val changeAddress = bitcoinCore.changeAddress() + return bitcoinCore.replacementTransaction(transactionHash, minFee, ReplacementType.Cancel(changeAddress)) + } + + fun send(replacementTransaction: ReplacementTransaction): FullTransaction { + return bitcoinCore.send(replacementTransaction) + } + + fun replacementTransactionInfo(transactionHash: String): Pair? { + return bitcoinCore.replacementTransactionInfo(transactionHash) + } + } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt index 7cfaf98a9..c5c0c2904 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt @@ -17,6 +17,7 @@ import io.horizontalsystems.bitcoincore.managers.IUnspentOutputSelector import io.horizontalsystems.bitcoincore.managers.RestoreKeyConverterChain import io.horizontalsystems.bitcoincore.managers.SyncManager import io.horizontalsystems.bitcoincore.managers.UnspentOutputSelectorChain +import io.horizontalsystems.bitcoincore.models.Address import io.horizontalsystems.bitcoincore.models.BalanceInfo import io.horizontalsystems.bitcoincore.models.BitcoinPaymentData import io.horizontalsystems.bitcoincore.models.BitcoinSendInfo @@ -36,6 +37,9 @@ import io.horizontalsystems.bitcoincore.network.peer.InventoryItemsHandlerChain import io.horizontalsystems.bitcoincore.network.peer.PeerGroup import io.horizontalsystems.bitcoincore.network.peer.PeerManager import io.horizontalsystems.bitcoincore.network.peer.PeerTaskHandlerChain +import io.horizontalsystems.bitcoincore.rbf.ReplacementTransaction +import io.horizontalsystems.bitcoincore.rbf.ReplacementTransactionBuilder +import io.horizontalsystems.bitcoincore.rbf.ReplacementType import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput import io.horizontalsystems.bitcoincore.storage.UnspentOutputInfo @@ -62,6 +66,7 @@ class BitcoinCore( private val restoreKeyConverterChain: RestoreKeyConverterChain, private val transactionCreator: TransactionCreator?, private val transactionFeeCalculator: TransactionFeeCalculator?, + private val replacementTransactionBuilder: ReplacementTransactionBuilder?, private val paymentAddressParser: PaymentAddressParser, private val syncManager: SyncManager, private val purpose: Purpose, @@ -263,6 +268,10 @@ class BitcoinCore( return addressConverter.convert(publicKeyManager.receivePublicKey(), purpose.scriptType).stringValue } + fun changeAddress(): Address { + return addressConverter.convert(publicKeyManager.changePublicKey(), purpose.scriptType) + } + fun usedAddresses(change: Boolean): List { return publicKeyManager.usedExternalPublicKeys(change).map { UsedAddress( @@ -439,6 +448,27 @@ class BitcoinCore( return dataProvider.getTransaction(hash) } + fun replacementTransaction(transactionHash: String, minFee: Long, type: ReplacementType): ReplacementTransaction { + val replacementTransactionBuilder = this.replacementTransactionBuilder ?: throw CoreError.ReadOnlyCore + + val (mutableTransaction, fullInfo, descendantTransactionHashes) = + replacementTransactionBuilder.replacementTransaction(transactionHash, minFee, type) + val info = dataProvider.transactionInfo(fullInfo) + return ReplacementTransaction(mutableTransaction, info, descendantTransactionHashes) + } + + fun send(replacementTransaction: ReplacementTransaction): FullTransaction { + val transactionCreator = this.transactionCreator ?: throw CoreError.ReadOnlyCore + + return transactionCreator.create(replacementTransaction.mutableTransaction) + } + + fun replacementTransactionInfo(transactionHash: String): Pair? { + val (fullInfo, feeRange) = this.replacementTransactionBuilder?.replacementInfo(transactionHash) ?: return null + + return Pair(dataProvider.transactionInfo(fullInfo), feeRange) + } + sealed class KitState { object Synced : KitState() class NotSynced(val exception: Throwable) : KitState() diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCoreBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCoreBuilder.kt index 53e7aec61..d951304e3 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCoreBuilder.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCoreBuilder.kt @@ -84,6 +84,7 @@ import io.horizontalsystems.bitcoincore.network.peer.MempoolTransactions import io.horizontalsystems.bitcoincore.network.peer.PeerAddressManager import io.horizontalsystems.bitcoincore.network.peer.PeerGroup import io.horizontalsystems.bitcoincore.network.peer.PeerManager +import io.horizontalsystems.bitcoincore.rbf.ReplacementTransactionBuilder import io.horizontalsystems.bitcoincore.serializers.BlockHeaderParser import io.horizontalsystems.bitcoincore.transactions.BlockTransactionProcessor import io.horizontalsystems.bitcoincore.transactions.PendingTransactionProcessor @@ -443,6 +444,7 @@ class BitcoinCoreBuilder { var transactionFeeCalculator: TransactionFeeCalculator? = null var transactionSender: TransactionSender? = null var transactionCreator: TransactionCreator? = null + var replacementTransactionBuilder: ReplacementTransactionBuilder? = null if (privateWallet != null) { val ecdsaInputSigner = EcdsaInputSigner(privateWallet, network) @@ -462,8 +464,7 @@ class BitcoinCoreBuilder { transactionDataSorterFactory ) val lockTimeSetter = LockTimeSetter(storage) - val signer = TransactionSigner(ecdsaInputSigner, schnorrInputSigner) - val transactionBuilder = TransactionBuilder(recipientSetter, outputSetter, inputSetter, signer, lockTimeSetter) + val transactionBuilder = TransactionBuilder(recipientSetter, outputSetter, inputSetter, lockTimeSetter) transactionFeeCalculator = TransactionFeeCalculator( recipientSetter, inputSetter, @@ -485,8 +486,11 @@ class BitcoinCoreBuilder { transactionSender = transactionSenderInstance transactionSendTimer.listener = transactionSender - - transactionCreator = TransactionCreator(transactionBuilder, pendingTransactionProcessor, transactionSenderInstance, bloomFilterManager) + val signer = TransactionSigner(ecdsaInputSigner, schnorrInputSigner) + transactionCreator = TransactionCreator(transactionBuilder, pendingTransactionProcessor, transactionSenderInstance, signer, bloomFilterManager) + replacementTransactionBuilder = ReplacementTransactionBuilder( + storage, transactionSizeCalculator, dustCalculator, metadataExtractor, pluginManager, unspentOutputProvider, publicKeyManager + ) } val bitcoinCore = BitcoinCore( @@ -497,6 +501,7 @@ class BitcoinCoreBuilder { restoreKeyConverterChain, transactionCreator, transactionFeeCalculator, + replacementTransactionBuilder, paymentAddressParser, syncManager, purpose, diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/BaseTransactionInfoConverter.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/BaseTransactionInfoConverter.kt index 3f1a3e8a0..84bb60fbe 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/BaseTransactionInfoConverter.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/BaseTransactionInfoConverter.kt @@ -44,6 +44,8 @@ class BaseTransactionInfoConverter(private val pluginManager: PluginManager) { outputsInfo.add(outputInfo) } + val rbfEnabled = fullTransaction.inputs.any { it.input.rbfEnabled } + return TransactionInfo( uid = transaction.uid, transactionHash = transaction.hash.toReversedHex(), @@ -56,7 +58,8 @@ class BaseTransactionInfoConverter(private val pluginManager: PluginManager) { blockHeight = fullTransaction.block?.height, timestamp = transaction.timestamp, status = TransactionStatus.getByCode(transaction.status) ?: TransactionStatus.NEW, - conflictingTxHash = transaction.conflictingTxHash?.toReversedHex() + conflictingTxHash = transaction.conflictingTxHash?.toReversedHex(), + rbfEnabled = rbfEnabled ) } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/DataProvider.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/DataProvider.kt index 5699b443d..0c0cca7f2 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/DataProvider.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/DataProvider.kt @@ -10,6 +10,7 @@ import io.horizontalsystems.bitcoincore.models.BlockInfo import io.horizontalsystems.bitcoincore.models.Transaction import io.horizontalsystems.bitcoincore.models.TransactionFilterType import io.horizontalsystems.bitcoincore.models.TransactionInfo +import io.horizontalsystems.bitcoincore.storage.FullTransactionInfo import io.horizontalsystems.bitcoincore.storage.TransactionWithBlock import io.reactivex.Single import io.reactivex.disposables.Disposable @@ -106,6 +107,10 @@ class DataProvider( fun getSpendableUtxo() = unspentOutputProvider.getSpendableUtxo() + fun transactionInfo(fullInfo: FullTransactionInfo): TransactionInfo { + return transactionInfoConverter.transactionInfo(fullInfo) + } + private fun blockInfo(block: Block) = BlockInfo( block.headerHash.toReversedHex(), block.height, diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt index 5ce4f0288..e15454264 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt @@ -109,6 +109,7 @@ interface IStorage { // InvalidTransaction fun getInvalidTransaction(hash: ByteArray): InvalidTransaction? + fun getDescendantTransactionsFullInfo(txHash: ByteArray): List fun moveTransactionToInvalidTransactions(invalidTransactions: List) fun moveInvalidTransactionToTransactions(invalidTransaction: InvalidTransaction, toTransactions: FullTransaction) fun deleteAllInvalidTransactions() diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt index d50caee3b..0b63a0120 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt @@ -5,21 +5,35 @@ import io.horizontalsystems.bitcoincore.core.PluginManager import io.horizontalsystems.bitcoincore.models.BalanceInfo import io.horizontalsystems.bitcoincore.storage.UnspentOutput -class UnspentOutputProvider(private val storage: IStorage, private val confirmationsThreshold: Int = 6, val pluginManager: PluginManager) : IUnspentOutputProvider { +class UnspentOutputProvider( + private val storage: IStorage, + private val confirmationsThreshold: Int = 6, + val pluginManager: PluginManager +) : IUnspentOutputProvider { + override fun getSpendableUtxo(): List { - return getConfirmedUtxo().filter { + return allUtxo().filter { pluginManager.isSpendable(it) } } fun getBalance(): BalanceInfo { - val spendable = getSpendableUtxo().map { it.output.value }.sum() - val unspendable = getUnspendableUtxo().map { it.output.value }.sum() + val spendable = getSpendableUtxo().sumOf { it.output.value } + val unspendable = getUnspendableUtxo().sumOf { it.output.value } return BalanceInfo(spendable, unspendable) } - private fun getConfirmedUtxo(): List { + fun getConfirmedUtxo(): List { + val lastBlockHeight = storage.lastBlock()?.height ?: 0 + + return storage.getUnspentOutputs().filter { + val block = it.block ?: return@filter false + return@filter block.height <= lastBlockHeight - confirmationsThreshold + 1 + } + } + + private fun allUtxo(): List { val unspentOutputs = storage.getUnspentOutputs() if (confirmationsThreshold == 0) return unspentOutputs @@ -27,10 +41,14 @@ class UnspentOutputProvider(private val storage: IStorage, private val confirmat val lastBlockHeight = storage.lastBlock()?.height ?: 0 return unspentOutputs.filter { + // If a transaction is an outgoing transaction, then it can be used + // even if it's not included in a block yet if (it.transaction.isOutgoing) { return@filter true } + // If a transaction is an incoming transaction, then it can be used + // only if it's included in a block and has enough number of confirmations val block = it.block ?: return@filter false if (block.height <= lastBlockHeight - confirmationsThreshold + 1) { return@filter true @@ -41,7 +59,7 @@ class UnspentOutputProvider(private val storage: IStorage, private val confirmat } private fun getUnspendableUtxo(): List { - return getConfirmedUtxo().filter { + return allUtxo().filter { !pluginManager.isSpendable(it) } } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInfo.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInfo.kt index cbd5f11a6..91995532b 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInfo.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInfo.kt @@ -18,19 +18,23 @@ open class TransactionInfo { var timestamp: Long = 0 var status: TransactionStatus = TransactionStatus.NEW var conflictingTxHash: String? = null - - constructor(uid: String, - transactionHash: String, - transactionIndex: Int, - inputs: List, - outputs: List, - amount: Long, - type: TransactionType, - fee: Long?, - blockHeight: Int?, - timestamp: Long, - status: TransactionStatus, - conflictingTxHash: String? = null) { + var rbfEnabled: Boolean = false + + constructor( + uid: String, + transactionHash: String, + transactionIndex: Int, + inputs: List, + outputs: List, + amount: Long, + type: TransactionType, + fee: Long?, + blockHeight: Int?, + timestamp: Long, + status: TransactionStatus, + conflictingTxHash: String? = null, + rbfEnabled: Boolean = false + ) { this.uid = uid this.transactionHash = transactionHash this.transactionIndex = transactionIndex @@ -43,6 +47,7 @@ open class TransactionInfo { this.timestamp = timestamp this.status = status this.conflictingTxHash = conflictingTxHash + this.rbfEnabled = rbfEnabled } @Throws @@ -61,6 +66,7 @@ open class TransactionInfo { status = TransactionStatus.getByCode(jsonObject.get("status").asInt()) ?: TransactionStatus.INVALID conflictingTxHash = jsonObject.get("conflictingTxHash")?.asString() + rbfEnabled = jsonObject.get("rbfEnabled")?.asBoolean() ?: false } private fun parseInputs(jsonArray: JsonArray): List { diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInput.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInput.kt index c6c539949..f5e1c7eed 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInput.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionInput.kt @@ -21,7 +21,7 @@ import io.horizontalsystems.bitcoincore.storage.WitnessConverter */ @Entity( - primaryKeys = ["previousOutputTxHash", "previousOutputIndex"], + primaryKeys = ["previousOutputTxHash", "previousOutputIndex", "sequence"], foreignKeys = [ForeignKey( entity = Transaction::class, parentColumns = ["hash"], @@ -37,7 +37,7 @@ class TransactionInput( val previousOutputTxHash: ByteArray, val previousOutputIndex: Long, var sigScript: ByteArray = byteArrayOf(), - var sequence: Long = 0xfffffffe + var sequence: Long ) { var transactionHash = byteArrayOf() @@ -47,3 +47,6 @@ class TransactionInput( @TypeConverters(WitnessConverter::class) var witness: List = listOf() } + +val TransactionInput.rbfEnabled: Boolean + get() = sequence < 0xfffffffe diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionOutput.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionOutput.kt index 77d628764..097c51b81 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionOutput.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/models/TransactionOutput.kt @@ -77,6 +77,22 @@ class TransactionOutput() { publicKey?.let { setPublicKey(it) } } + constructor(output: TransactionOutput) : this() { + value = output.value + lockingScript = output.lockingScript + redeemScript = output.redeemScript + index = output.index + transactionHash = output.transactionHash + publicKeyPath = output.publicKeyPath + changeOutput = output.changeOutput + scriptType = output.scriptType + lockingScriptPayload = output.lockingScriptPayload + address = output.address + failedToSpend = output.failedToSpend + pluginId = output.pluginId + pluginData = output.pluginData + } + fun setPublicKey(publicKey: PublicKey) { this.publicKeyPath = publicKey.path this.changeOutput = !publicKey.external diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt new file mode 100644 index 000000000..d64bdb0ab --- /dev/null +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt @@ -0,0 +1,10 @@ +package io.horizontalsystems.bitcoincore.rbf + +import io.horizontalsystems.bitcoincore.models.TransactionInfo +import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction + +data class ReplacementTransaction( + internal val mutableTransaction: MutableTransaction, + val info: TransactionInfo, + val descendantTransactionHashes: List +) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt new file mode 100644 index 000000000..72545fad8 --- /dev/null +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt @@ -0,0 +1,313 @@ +package io.horizontalsystems.bitcoincore.rbf + +import io.horizontalsystems.bitcoincore.DustCalculator +import io.horizontalsystems.bitcoincore.core.IPublicKeyManager +import io.horizontalsystems.bitcoincore.core.IStorage +import io.horizontalsystems.bitcoincore.core.PluginManager +import io.horizontalsystems.bitcoincore.extensions.toReversedByteArray +import io.horizontalsystems.bitcoincore.extensions.toReversedHex +import io.horizontalsystems.bitcoincore.managers.UnspentOutputProvider +import io.horizontalsystems.bitcoincore.models.Address +import io.horizontalsystems.bitcoincore.models.PublicKey +import io.horizontalsystems.bitcoincore.models.TransactionInput +import io.horizontalsystems.bitcoincore.models.TransactionOutput +import io.horizontalsystems.bitcoincore.models.TransactionType +import io.horizontalsystems.bitcoincore.models.rbfEnabled +import io.horizontalsystems.bitcoincore.storage.FullTransactionInfo +import io.horizontalsystems.bitcoincore.storage.InputToSign +import io.horizontalsystems.bitcoincore.storage.InputWithPreviousOutput +import io.horizontalsystems.bitcoincore.storage.UnspentOutput +import io.horizontalsystems.bitcoincore.transactions.TransactionSizeCalculator +import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction +import io.horizontalsystems.bitcoincore.transactions.extractors.TransactionMetadataExtractor +import io.horizontalsystems.bitcoincore.utils.ShuffleSorter + +class ReplacementTransactionBuilder( + private val storage: IStorage, + private val sizeCalculator: TransactionSizeCalculator, + private val dustCalculator: DustCalculator, + private val metadataExtractor: TransactionMetadataExtractor, + private val pluginManager: PluginManager, + private val unspentOutputProvider: UnspentOutputProvider, + private val publicKeyManager: IPublicKeyManager, +) { + + private fun replacementTransaction( + minFee: Long, + minFeeRate: Int, + utxo: List, + fixedOutputs: List, + outputs: List + ): Pair, /*Fee*/Long>? { + + var minFee = minFee + val size = sizeCalculator.transactionSize(previousOutputs = utxo, outputs = fixedOutputs + outputs) + + val inputsValue = utxo.sumOf { it.value } + val outputsValue = (fixedOutputs + outputs).sumOf { it.value } + val fee = inputsValue - outputsValue + val feeRate = fee / size + + if (feeRate < minFeeRate) { + minFee = minFeeRate * size + } + + if (fee >= minFee) { + return Pair(outputs, fee) + } + + if (outputs.isEmpty()) { + return null + } + + val output = TransactionOutput(outputs.first()) + output.value = output.value - (minFee - fee) + + if (output.value > dustCalculator.dust(output.scriptType)) { + return Pair(listOf(output) + outputs.drop(1), fee) + } + + return null + } + + private fun incrementedSequence(input: TransactionInput): Long { + return input.sequence + 1 // TODO: increment locked inputs sequence + } + + private fun inputToSign(previousOutput: TransactionOutput, publicKey: PublicKey, sequence: Long): InputToSign { + val transactionInput = TransactionInput(previousOutput.transactionHash, previousOutput.index.toLong(), sequence = sequence) + + return InputToSign(transactionInput, previousOutput, publicKey) + } + + private fun setInputs( + mutableTransaction: MutableTransaction, + originalInputs: List, + additionalInputs: List + ) { + additionalInputs.map { utxo -> + mutableTransaction.addInput(inputToSign(previousOutput = utxo.output, publicKey = utxo.publicKey, sequence = 0x0)) + } + + pluginManager.processInputs(mutableTransaction) + + originalInputs.map { inputWithPreviousOutput -> + val previousOutput = inputWithPreviousOutput.previousOutput ?: throw BuildError.InvalidTransaction + val publicKey = previousOutput.publicKeyPath?.let { publicKeyManager.getPublicKeyByPath(it) } ?: throw BuildError.InvalidTransaction + mutableTransaction.addInput(inputToSign(previousOutput = previousOutput, publicKey, incrementedSequence(inputWithPreviousOutput.input))) + } + } + + private fun setOutputs(mutableTransaction: MutableTransaction, outputs: List) { + val sorted = ShuffleSorter().sortOutputs(outputs) + sorted.forEachIndexed { index, transactionOutput -> + transactionOutput.index = index + } + + mutableTransaction.outputs = sorted + } + + private fun speedUpReplacement( + originalFullInfo: FullTransactionInfo, + minFee: Long, + originalFeeRate: Int, + fixedUtxo: List + ): MutableTransaction? { + // If an output has a pluginId, it most probably has a time-locked value and it shouldn't be altered. + val fixedOutputs = originalFullInfo.outputs.filter { it.publicKeyPath == null || it.pluginId != null } + val myOutputs = originalFullInfo.outputs.filter { it.publicKeyPath != null && it.pluginId == null } + val myChangeOutputs = myOutputs.filter { it.changeOutput }.sortedBy { it.value } + val myExternalOutputs = myOutputs.filter { !it.changeOutput }.sortedBy { it.value } + + val sortedOutputs = myChangeOutputs + myExternalOutputs + val unusedUtxo = unspentOutputProvider.getSpendableUtxo().sortedBy { it.output.value } + var optimalReplacement: Triple, /*outputs*/ List, /*fee*/ Long>? = null + + var utxoCount = 0 + do { + val utxo = unusedUtxo.take(utxoCount) + var outputsCount = sortedOutputs.size + + do { + val outputs = sortedOutputs.takeLast(outputsCount) + + replacementTransaction( + minFee = minFee, + minFeeRate = originalFeeRate, + utxo = fixedUtxo + utxo.map { it.output }, + fixedOutputs = fixedOutputs, + outputs = outputs + )?.let { (outputs, fee) -> + optimalReplacement.let { _optimalReplacement -> + if (_optimalReplacement != null) { + if (_optimalReplacement.third > fee) { + optimalReplacement = Triple(utxo, outputs, fee) + } + } else { + optimalReplacement = Triple(utxo, outputs, fee) + } + } + } + + outputsCount-- + } while (outputsCount >= 0) + + utxoCount++ + } while (utxoCount <= unusedUtxo.size) + + return optimalReplacement?.let { (inputs, outputs, _) -> + val mutableTransaction = MutableTransaction() + setInputs( + mutableTransaction = mutableTransaction, + originalInputs = originalFullInfo.inputs, + additionalInputs = inputs + ) + setOutputs( + mutableTransaction = mutableTransaction, + outputs = fixedOutputs + outputs + ) + mutableTransaction + } + } + + private fun cancelReplacement( + originalFullInfo: FullTransactionInfo, + minFee: Long, + originalFee: Long, + originalFeeRate: Int, + fixedUtxo: List, + changeAddress: Address + ): MutableTransaction? { + val unusedUtxo = unspentOutputProvider.getSpendableUtxo().sortedBy { it.output.value } + val originalInputsValue = fixedUtxo.sumOf { it.value } + + var optimalReplacement: Triple, /*outputs*/ List, /*fee*/ Long>? = null + + var utxoCount = 0 + val outputs = listOf( + TransactionOutput( + value = originalInputsValue - originalFee, + index = 0, + script = changeAddress.lockingScript, + type = changeAddress.scriptType, + address = changeAddress.stringValue, + lockingScriptPayload = changeAddress.lockingScriptPayload + ) + ) + do { + val utxo = unusedUtxo.take(utxoCount) + + replacementTransaction( + minFee = minFee, + minFeeRate = originalFeeRate, + utxo = fixedUtxo + utxo.map { it.output }, + fixedOutputs = listOf(), + outputs = outputs + )?.let { (outputs, fee) -> + optimalReplacement.let { _optimalReplacement -> + if (_optimalReplacement != null) { + if (_optimalReplacement.third > fee) { + optimalReplacement = Triple(utxo, outputs, fee) + } + } else { + optimalReplacement = Triple(utxo, outputs, fee) + } + } + } + + utxoCount++ + } while (utxoCount <= unusedUtxo.size) + + + return optimalReplacement?.let { (inputs, outputs, _) -> + val mutableTransaction = MutableTransaction() + setInputs( + mutableTransaction = mutableTransaction, + originalInputs = originalFullInfo.inputs, + additionalInputs = inputs + ) + setOutputs( + mutableTransaction = mutableTransaction, + outputs = outputs + ) + mutableTransaction + } + } + + fun replacementTransaction( + transactionHash: String, + minFee: Long, + type: ReplacementType + ): Triple> { + // TODO: Need to check that this transaction has not been replaced already + val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) ?: throw BuildError.InvalidTransaction + check(originalFullInfo.block == null) { "Transaction already in block" } + + val originalFee = originalFullInfo.metadata.fee + checkNotNull(originalFee) { "No fee for original transaction" } + + check(originalFullInfo.metadata.type != TransactionType.Incoming) { "Can replace only outgoing transaction" } + + val fixedUtxo = originalFullInfo.inputs.mapNotNull { it.previousOutput } + + check(originalFullInfo.inputs.size == fixedUtxo.size) { "No previous output" } + + check(originalFullInfo.inputs.any { it.input.rbfEnabled }) { "Rbf not enabled" } + + val originalSize = sizeCalculator.transactionSize(previousOutputs = fixedUtxo, outputs = originalFullInfo.outputs) + + val originalFeeRate = (originalFee / originalSize).toInt() + val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) + val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } + + check(absoluteFee <= minFee) { "Fee too low" } + + val mutableTransaction = when (type) { + ReplacementType.SpeedUp -> speedUpReplacement(originalFullInfo, minFee, originalFeeRate, fixedUtxo) + is ReplacementType.Cancel -> cancelReplacement(originalFullInfo, minFee, originalFee, originalFeeRate, fixedUtxo, type.changeAddress) + } + + checkNotNull(mutableTransaction) { "Unable to replace" } + + val fullTransaction = mutableTransaction.build() + metadataExtractor.extract(fullTransaction) + val metadata = fullTransaction.metadata + + return Triple( + mutableTransaction, + FullTransactionInfo( + block = null, + header = mutableTransaction.transaction, + inputs = mutableTransaction.inputsToSign.map { + InputWithPreviousOutput(it.input, it.previousOutput) + }, + outputs = mutableTransaction.outputs, + metadata = metadata + ), + descendantTransactions.map { it.metadata.transactionHash.toReversedHex() } + ) + } + + fun replacementInfo(transactionHash: String): Pair? { + val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) ?: return null + check(originalFullInfo.block == null) { "Transaction already in block" } + check(originalFullInfo.metadata.type != TransactionType.Incoming) { "Can replace only outgoing transaction" } + + val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) + val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } + val confirmedUtxoTotalValue = unspentOutputProvider.getConfirmedUtxo().sumOf { it.output.value } + val myOutputs = originalFullInfo.outputs.filter { it.publicKeyPath != null && it.pluginId == null } + val myOutputsTotalValue = myOutputs.sumOf { it.value } + + val feeRange = LongRange(absoluteFee, absoluteFee + myOutputsTotalValue + confirmedUtxoTotalValue) + return Pair(originalFullInfo, feeRange) + } + + sealed class BuildError : Throwable() { + object InvalidTransaction : BuildError() + object NoPreviousOutput : BuildError() + object FeeTooLow : BuildError() + object RbfNotEnabled : BuildError() + object UnableToReplace : BuildError() + } +} diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt new file mode 100644 index 000000000..198587b21 --- /dev/null +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt @@ -0,0 +1,8 @@ +package io.horizontalsystems.bitcoincore.rbf + +import io.horizontalsystems.bitcoincore.models.Address + +sealed class ReplacementType { + object SpeedUp : ReplacementType() + data class Cancel(val changeAddress: Address) : ReplacementType() +} diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/CoreDatabase.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/CoreDatabase.kt index 917bb5e9f..dedf4ea4a 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/CoreDatabase.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/CoreDatabase.kt @@ -10,7 +10,7 @@ import android.content.Context import io.horizontalsystems.bitcoincore.models.* import io.horizontalsystems.bitcoincore.storage.migrations.* -@Database(version = 17, exportSchema = false, entities = [ +@Database(version = 18, exportSchema = false, entities = [ BlockchainState::class, PeerAddress::class, BlockHash::class, @@ -49,6 +49,7 @@ abstract class CoreDatabase : RoomDatabase() { return Room.databaseBuilder(context, CoreDatabase::class.java, dbName) .allowMainThreadQueries() .addMigrations( + Migration_17_18, Migration_16_17, Migration_15_16, Migration_14_15, diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/DataObjects.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/DataObjects.kt index d6dcd8d52..7ed3ab628 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/DataObjects.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/DataObjects.kt @@ -62,25 +62,17 @@ class PublicKeyWithUsedState( get() = usedCount > 0 } -class PreviousOutput( - val publicKeyPath: String?, - val value: Long, - val index: Int, - - // PreviousOutput is intended to be used with TransactionInput. - // Here we use outputTransactionHash, since the TransactionInput has field `transactionHash` field - val outputTransactionHash: ByteArray -) - class InputWithPreviousOutput( - @Embedded val input: TransactionInput, - @Embedded val previousOutput: PreviousOutput?) + val input: TransactionInput, + val previousOutput: TransactionOutput? +) class UnspentOutput( - @Embedded val output: TransactionOutput, - @Embedded val publicKey: PublicKey, - @Embedded val transaction: Transaction, - @Embedded val block: Block?) + @Embedded val output: TransactionOutput, + @Embedded val publicKey: PublicKey, + @Embedded val transaction: Transaction, + @Embedded val block: Block? +) class UnspentOutputInfo( val outputIndex: Int, diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt index f42496073..89e71eeeb 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt @@ -375,6 +375,20 @@ open class Storage(protected open val store: CoreDatabase) : IStorage { return store.transaction.getInvalidTransaction(hash) } + override fun getDescendantTransactionsFullInfo(txHash: ByteArray): List { + val fullTransactionInfo = getFullTransactionInfo(txHash) ?: return listOf() + val list = mutableListOf(fullTransactionInfo) + + val inputs = getTransactionInputsByPrevOutputTxHash(fullTransactionInfo.header.hash) + + inputs.forEach { input -> + val descendantTxs = getDescendantTransactionsFullInfo(input.transactionHash) + list.addAll(descendantTxs) + } + + return list + } + override fun moveTransactionToInvalidTransactions(invalidTransactions: List) { store.runInTransaction { invalidTransactions.forEach { invalidTransaction -> @@ -383,7 +397,7 @@ open class Storage(protected open val store: CoreDatabase) : IStorage { val inputs = store.input.getInputsWithPrevouts(listOf(invalidTransaction.hash)) inputs.forEach { input -> input.previousOutput?.let { - store.output.markFailedToSpend(it.outputTransactionHash, it.index) + store.output.markFailedToSpend(it.transactionHash, it.index) } } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt index 303eb6c11..bd150d088 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt @@ -2,6 +2,7 @@ package io.horizontalsystems.bitcoincore.storage import androidx.room.* import io.horizontalsystems.bitcoincore.models.TransactionInput +import io.horizontalsystems.bitcoincore.models.TransactionOutput @Dao interface TransactionInputDao { @@ -26,18 +27,27 @@ interface TransactionInputDao { @Query("select * from TransactionInput where transactionHash IN (:hashes)") fun getTransactionInputs(hashes: List): List - @Query(""" - SELECT - inputs.*, - outputs.publicKeyPath, - outputs.value, - outputs.`index`, - outputs.transactionHash as outputTransactionHash - FROM TransactionInput as inputs - LEFT JOIN TransactionOutput AS outputs ON outputs.transactionHash = inputs.previousOutputTxHash AND outputs.`index` = inputs.previousOutputIndex - WHERE inputs.transactionHash IN(:txHashes) - """) - fun getInputsWithPrevouts(txHashes: List): List +// @Query( +// """ +// SELECT +// inputs.*, +// outputs.* +// FROM TransactionInput as inputs +// LEFT JOIN TransactionOutput AS outputs ON outputs.transactionHash = inputs.previousOutputTxHash AND outputs.`index` = inputs.previousOutputIndex +// WHERE inputs.transactionHash IN(:txHashes) +// """ +// ) +// fun getInputsWithPrevouts(txHashes: List): List + + @Query("select * from TransactionOutput where transactionHash=:transactionHash AND `index`=:index limit 1") + fun output(transactionHash: ByteArray, index: Long): TransactionOutput? + + @Transaction + fun getInputsWithPrevouts(txHashes: List) = + getTransactionInputs(txHashes).map { input -> + val prevOutput = output(input.previousOutputTxHash, input.previousOutputIndex) + InputWithPreviousOutput(input, prevOutput) + } @Query("SELECT * FROM TransactionInput WHERE previousOutputTxHash = :txHash") fun getInputsByPrevOutputTxHash(txHash: ByteArray): List diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/migrations/Migration_17_18.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/migrations/Migration_17_18.kt new file mode 100644 index 000000000..8f36d73f7 --- /dev/null +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/migrations/Migration_17_18.kt @@ -0,0 +1,17 @@ +package io.horizontalsystems.bitcoincore.storage.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +object Migration_17_18 : Migration(17, 18) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `TransactionInput` RENAME TO `TmpTransactionInput`") + + database.execSQL("CREATE TABLE IF NOT EXISTS `TransactionInput` (`previousOutputTxHash` BLOB NOT NULL, `previousOutputIndex` INTEGER NOT NULL, `sigScript` BLOB NOT NULL, `sequence` INTEGER NOT NULL, `transactionHash` BLOB NOT NULL, `lockingScriptPayload` BLOB, `address` TEXT, `witness` TEXT NOT NULL, PRIMARY KEY(`previousOutputTxHash`, `previousOutputIndex`, `sequence`), FOREIGN KEY(`transactionHash`) REFERENCES `Transaction`(`hash`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("INSERT OR REPLACE INTO `TransactionInput` (`transactionHash`,`lockingScriptPayload`,`address`,`witness`,`previousOutputTxHash`,`previousOutputIndex`,`sigScript`,`sequence`) SELECT `transactionHash`,`lockingScriptPayload`,`address`,`witness`,`previousOutputTxHash`,`previousOutputIndex`,`sigScript`,`sequence` FROM `TmpTransactionInput`") + + database.execSQL("DROP TABLE IF EXISTS `TmpTransactionInput`") + } + +} diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionCreator.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionCreator.kt index d120096ce..3b3d036b7 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionCreator.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionCreator.kt @@ -5,12 +5,15 @@ import io.horizontalsystems.bitcoincore.managers.BloomFilterManager import io.horizontalsystems.bitcoincore.models.TransactionDataSortType import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput +import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction import io.horizontalsystems.bitcoincore.transactions.builder.TransactionBuilder +import io.horizontalsystems.bitcoincore.transactions.builder.TransactionSigner class TransactionCreator( private val builder: TransactionBuilder, private val processor: PendingTransactionProcessor, private val transactionSender: TransactionSender, + private val transactionSigner: TransactionSigner, private val bloomFilterManager: BloomFilterManager ) { @@ -25,18 +28,18 @@ class TransactionCreator( pluginData: Map, rbfEnabled: Boolean ): FullTransaction { - return create { - builder.buildTransaction( - toAddress = toAddress, - value = value, - feeRate = feeRate, - senderPay = senderPay, - sortType = sortType, - unspentOutputs = unspentOutputs, - pluginData = pluginData, - rbfEnabled = rbfEnabled - ) - } + val mutableTransaction = builder.buildTransaction( + toAddress = toAddress, + value = value, + feeRate = feeRate, + senderPay = senderPay, + sortType = sortType, + unspentOutputs = unspentOutputs, + pluginData = pluginData, + rbfEnabled = rbfEnabled + ) + + return create(mutableTransaction) } @Throws @@ -47,15 +50,22 @@ class TransactionCreator( sortType: TransactionDataSortType, rbfEnabled: Boolean ): FullTransaction { - return create { - builder.buildTransaction(unspentOutput, toAddress, feeRate, sortType, rbfEnabled) - } + val mutableTransaction = builder.buildTransaction(unspentOutput, toAddress, feeRate, sortType, rbfEnabled) + + return create(mutableTransaction) } - private fun create(transactionBuilderFunction: () -> FullTransaction): FullTransaction { - transactionSender.canSendTransaction() + fun create(mutableTransaction: MutableTransaction): FullTransaction { + transactionSigner.sign(mutableTransaction) - val transaction = transactionBuilderFunction.invoke() + val fullTransaction = mutableTransaction.build() + processAndSend(fullTransaction) + + return fullTransaction + } + + private fun processAndSend(transaction: FullTransaction): FullTransaction { + transactionSender.canSendTransaction() try { processor.processCreated(transaction) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionInvalidator.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionInvalidator.kt index 15d4e6019..dce874d74 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionInvalidator.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionInvalidator.kt @@ -14,7 +14,7 @@ class TransactionInvalidator( ) { fun invalidate(transaction: Transaction) { - val invalidTransactionsFullInfo = getDescendantTransactionsFullInfo(transaction.hash) + val invalidTransactionsFullInfo = storage.getDescendantTransactionsFullInfo(transaction.hash) if (invalidTransactionsFullInfo.isEmpty()) return @@ -32,18 +32,4 @@ class TransactionInvalidator( listener.onTransactionsUpdate(listOf(), invalidTransactions, null) } - private fun getDescendantTransactionsFullInfo(txHash: ByteArray): List { - val fullTransactionInfo = storage.getFullTransactionInfo(txHash) ?: return listOf() - val list = mutableListOf(fullTransactionInfo) - - val inputs = storage.getTransactionInputsByPrevOutputTxHash(fullTransactionInfo.header.hash) - - inputs.forEach { input -> - val descendantTxs = getDescendantTransactionsFullInfo(input.transactionHash) - list.addAll(descendantTxs) - } - - return list - } - } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionSizeCalculator.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionSizeCalculator.kt index b4859fc80..bd62945ac 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionSizeCalculator.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionSizeCalculator.kt @@ -33,7 +33,11 @@ class TransactionSizeCalculator { ) fun outputSize(scripType: ScriptType): Int { - return 8 + 1 + getLockingScriptSize(scripType) + return outputSize(lockingScriptSize = getLockingScriptSize(scripType)) + } + + fun outputSize(lockingScriptSize: Int): Int { + return 8 + 1 + lockingScriptSize } fun inputSize(scriptType: ScriptType): Int { @@ -72,6 +76,23 @@ class TransactionSizeCalculator { return 32 + 4 + 1 + scriptSigLength + 4 // PreviousOutputHex + InputIndex + sigLength + scriptSig + sequence } + fun transactionSize(previousOutputs: List, outputs: List): Long { + val txIsWitness = previousOutputs.any { it.scriptType.isWitness } + val txWeight = if (txIsWitness) witnessTx else legacyTx + val inputWeight = previousOutputs.sumOf { inputSize(it) * 4 + if (txIsWitness) witnessSize(it.scriptType) else 0 } + + var outputWeight = 0 + for (output in outputs) { + when (output.scriptType) { + ScriptType.NULL_DATA -> outputWeight += outputSize(lockingScriptSize = output.lockingScript.size) * 4 + ScriptType.UNKNOWN -> throw IllegalStateException("Unknown output script type") + else -> outputSize(outputSize(output.scriptType)) * 4 + } + } + + return toBytes(txWeight + inputWeight + outputWeight).toLong() + } + fun transactionSize(previousOutputs: List, outputs: List, pluginDataOutputSize: Int): Long { val txIsWitness = previousOutputs.any { it.scriptType.isWitness } val txWeight = if (txIsWitness) witnessTx else legacyTx diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/builder/TransactionBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/builder/TransactionBuilder.kt index b6a7ae457..ec2b7ad4c 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/builder/TransactionBuilder.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/builder/TransactionBuilder.kt @@ -10,7 +10,6 @@ class TransactionBuilder( private val recipientSetter: IRecipientSetter, private val outputSetter: OutputSetter, private val inputSetter: InputSetter, - private val signer: TransactionSigner, private val lockTimeSetter: LockTimeSetter ) { @@ -23,7 +22,7 @@ class TransactionBuilder( unspentOutputs: List?, pluginData: Map, rbfEnabled: Boolean - ): FullTransaction { + ): MutableTransaction { val mutableTransaction = MutableTransaction() recipientSetter.setRecipient(mutableTransaction, toAddress, value, pluginData, false) @@ -31,9 +30,8 @@ class TransactionBuilder( lockTimeSetter.setLockTime(mutableTransaction) outputSetter.setOutputs(mutableTransaction, sortType) - signer.sign(mutableTransaction) - return mutableTransaction.build() + return mutableTransaction } fun buildTransaction( @@ -42,7 +40,7 @@ class TransactionBuilder( feeRate: Int, sortType: TransactionDataSortType, rbfEnabled: Boolean - ): FullTransaction { + ): MutableTransaction { val mutableTransaction = MutableTransaction(false) recipientSetter.setRecipient(mutableTransaction, toAddress, unspentOutput.output.value, mapOf(), false) @@ -50,9 +48,8 @@ class TransactionBuilder( lockTimeSetter.setLockTime(mutableTransaction) outputSetter.setOutputs(mutableTransaction, sortType) - signer.sign(mutableTransaction) - return mutableTransaction.build() + return mutableTransaction } open class BuilderException : Exception() { From d6fbf4ca0c3e600237161f33b8bd58988c0bca3f Mon Sep 17 00:00:00 2001 From: chyngyz Date: Wed, 28 Feb 2024 17:29:58 +0600 Subject: [PATCH 2/6] RBF improvements - Improve conflict resolver. Compare by input sequence - Make UTXO of pending sent transactions unspendable while the transaction is in "new" state - Minor bugfixes and refactoring --- .../bitcoincore/core/Interfaces.kt | 2 ++ .../managers/UnspentOutputProvider.kt | 20 ++++++----- .../bitcoincore/rbf/ReplacementTransaction.kt | 2 +- .../rbf/ReplacementTransactionBuilder.kt | 7 ++-- .../bitcoincore/storage/Storage.kt | 30 ++++++++++++++++ .../transactions/BlockTransactionProcessor.kt | 30 +++++++++------- .../PendingTransactionProcessor.kt | 35 +++++++++--------- .../TransactionConflictsResolver.kt | 36 ++++++++++++++----- 8 files changed, 112 insertions(+), 50 deletions(-) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt index e15454264..6cf30f9e1 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/Interfaces.kt @@ -92,6 +92,7 @@ interface IStorage { fun getTransaction(hash: ByteArray): Transaction? fun getFullTransaction(hash: ByteArray): FullTransaction? + fun getFullTransactions(transactions: List): List fun getValidOrInvalidTransaction(uid: String): Transaction? fun getTransactionOfOutput(output: TransactionOutput): Transaction? fun addTransaction(transaction: FullTransaction) @@ -110,6 +111,7 @@ interface IStorage { fun getInvalidTransaction(hash: ByteArray): InvalidTransaction? fun getDescendantTransactionsFullInfo(txHash: ByteArray): List + fun getDescendantTransactions(txHash: ByteArray): List fun moveTransactionToInvalidTransactions(invalidTransactions: List) fun moveInvalidTransactionToTransactions(invalidTransaction: InvalidTransaction, toTransactions: FullTransaction) fun deleteAllInvalidTransactions() diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt index 0b63a0120..a3d0e3aca 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/managers/UnspentOutputProvider.kt @@ -3,6 +3,7 @@ package io.horizontalsystems.bitcoincore.managers import io.horizontalsystems.bitcoincore.core.IStorage import io.horizontalsystems.bitcoincore.core.PluginManager import io.horizontalsystems.bitcoincore.models.BalanceInfo +import io.horizontalsystems.bitcoincore.models.Transaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput class UnspentOutputProvider( @@ -13,7 +14,13 @@ class UnspentOutputProvider( override fun getSpendableUtxo(): List { return allUtxo().filter { - pluginManager.isSpendable(it) + pluginManager.isSpendable(it) && it.transaction.status == Transaction.Status.RELAYED + } + } + + private fun getUnspendableUtxo(): List { + return allUtxo().filter { + !pluginManager.isSpendable(it) || it.transaction.status != Transaction.Status.RELAYED } } @@ -24,10 +31,11 @@ class UnspentOutputProvider( return BalanceInfo(spendable, unspendable) } - fun getConfirmedUtxo(): List { + // Only confirmed spendable outputs + fun getConfirmedSpendableUtxo(): List { val lastBlockHeight = storage.lastBlock()?.height ?: 0 - return storage.getUnspentOutputs().filter { + return getSpendableUtxo().filter { val block = it.block ?: return@filter false return@filter block.height <= lastBlockHeight - confirmationsThreshold + 1 } @@ -57,10 +65,4 @@ class UnspentOutputProvider( false } } - - private fun getUnspendableUtxo(): List { - return allUtxo().filter { - !pluginManager.isSpendable(it) - } - } } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt index d64bdb0ab..0fa5b30f5 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt @@ -6,5 +6,5 @@ import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction data class ReplacementTransaction( internal val mutableTransaction: MutableTransaction, val info: TransactionInfo, - val descendantTransactionHashes: List + val replacedTransactionHashes: List ) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt index 72545fad8..29b182edd 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt @@ -120,7 +120,7 @@ class ReplacementTransactionBuilder( val myExternalOutputs = myOutputs.filter { !it.changeOutput }.sortedBy { it.value } val sortedOutputs = myChangeOutputs + myExternalOutputs - val unusedUtxo = unspentOutputProvider.getSpendableUtxo().sortedBy { it.output.value } + val unusedUtxo = unspentOutputProvider.getConfirmedSpendableUtxo().sortedBy { it.output.value } var optimalReplacement: Triple, /*outputs*/ List, /*fee*/ Long>? = null var utxoCount = 0 @@ -178,7 +178,7 @@ class ReplacementTransactionBuilder( fixedUtxo: List, changeAddress: Address ): MutableTransaction? { - val unusedUtxo = unspentOutputProvider.getSpendableUtxo().sortedBy { it.output.value } + val unusedUtxo = unspentOutputProvider.getConfirmedSpendableUtxo().sortedBy { it.output.value } val originalInputsValue = fixedUtxo.sumOf { it.value } var optimalReplacement: Triple, /*outputs*/ List, /*fee*/ Long>? = null @@ -260,6 +260,7 @@ class ReplacementTransactionBuilder( val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } + check(descendantTransactions.all { it.header.conflictingTxHash == null }) { "Already replaced"} check(absoluteFee <= minFee) { "Fee too low" } val mutableTransaction = when (type) { @@ -295,7 +296,7 @@ class ReplacementTransactionBuilder( val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } - val confirmedUtxoTotalValue = unspentOutputProvider.getConfirmedUtxo().sumOf { it.output.value } + val confirmedUtxoTotalValue = unspentOutputProvider.getConfirmedSpendableUtxo().sumOf { it.output.value } val myOutputs = originalFullInfo.outputs.filter { it.publicKeyPath != null && it.pluginId == null } val myOutputsTotalValue = myOutputs.sumOf { it.value } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt index 89e71eeeb..8dcbf33a5 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt @@ -15,6 +15,7 @@ import io.horizontalsystems.bitcoincore.models.SentTransaction import io.horizontalsystems.bitcoincore.models.Transaction import io.horizontalsystems.bitcoincore.models.TransactionFilterType import io.horizontalsystems.bitcoincore.models.TransactionInput +import io.horizontalsystems.bitcoincore.models.TransactionMetadata import io.horizontalsystems.bitcoincore.models.TransactionOutput import io.horizontalsystems.bitcoincore.transactions.scripts.ScriptType import kotlin.math.max @@ -273,6 +274,21 @@ open class Storage(protected open val store: CoreDatabase) : IStorage { return store.transaction.getByHash(hash)?.let { convertToFullTransaction(it) } } + override fun getFullTransactions(transactions: List): List { + val hashes = transactions.map { it.hash } + val inputsByTransaction = store.input.getTransactionInputs(hashes).groupBy { it.transactionHash } + val outputsByTransaction = store.output.getTransactionsOutputs(hashes).groupBy { it.transactionHash } + val metadataByTransaction = store.transactionMetadata.getTransactionMetadata(hashes).associateBy { it.transactionHash } + + return transactions.map { transaction -> + val inputs = inputsByTransaction[transaction.hash] ?: listOf() + val outputs = outputsByTransaction[transaction.hash] ?: listOf() + FullTransaction(transaction, inputs, outputs, false).apply { + metadata = metadataByTransaction[transaction.hash] ?: TransactionMetadata(transaction.hash) + } + } + } + override fun getValidOrInvalidTransaction(uid: String): Transaction? { return store.transaction.getValidOrInvalidByUid(uid) } @@ -389,6 +405,20 @@ open class Storage(protected open val store: CoreDatabase) : IStorage { return list } + override fun getDescendantTransactions(txHash: ByteArray): List { + val transaction = getTransaction(txHash) ?: return listOf() + val list = mutableListOf(transaction) + + val inputs = getTransactionInputsByPrevOutputTxHash(txHash) + + inputs.forEach { input -> + val descendantTxs = getDescendantTransactions(input.transactionHash) + list.addAll(descendantTxs) + } + + return list + } + override fun moveTransactionToInvalidTransactions(invalidTransactions: List) { store.runInTransaction { invalidTransactions.forEach { invalidTransaction -> diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/BlockTransactionProcessor.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/BlockTransactionProcessor.kt index dad92d96b..6cc58326a 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/BlockTransactionProcessor.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/BlockTransactionProcessor.kt @@ -7,23 +7,30 @@ import io.horizontalsystems.bitcoincore.core.IStorage import io.horizontalsystems.bitcoincore.core.inTopologicalOrder import io.horizontalsystems.bitcoincore.managers.BloomFilterManager import io.horizontalsystems.bitcoincore.managers.IIrregularOutputFinder -import io.horizontalsystems.bitcoincore.managers.PublicKeyManager import io.horizontalsystems.bitcoincore.models.Block import io.horizontalsystems.bitcoincore.models.Transaction import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.transactions.extractors.TransactionExtractor class BlockTransactionProcessor( - private val storage: IStorage, - private val extractor: TransactionExtractor, - private val publicKeyManager: IPublicKeyManager, - private val irregularOutputFinder: IIrregularOutputFinder, - private val dataListener: IBlockchainDataListener, - private val conflictsResolver: TransactionConflictsResolver, - private val invalidator: TransactionInvalidator) { + private val storage: IStorage, + private val extractor: TransactionExtractor, + private val publicKeyManager: IPublicKeyManager, + private val irregularOutputFinder: IIrregularOutputFinder, + private val dataListener: IBlockchainDataListener, + private val conflictsResolver: TransactionConflictsResolver, + private val invalidator: TransactionInvalidator +) { var transactionListener: WatchedTransactionManager? = null + private fun resolveConflicts(fullTransaction: FullTransaction) { + for (transaction in conflictsResolver.getTransactionsConflictingWithInBlockTransaction(fullTransaction)) { + transaction.conflictingTxHash = fullTransaction.header.hash + invalidator.invalidate(transaction) + } + } + @Throws(BloomFilterManager.BloomFilterExpired::class) fun processReceived(transactions: List, block: Block, skipCheckBloomFilter: Boolean) { var needToUpdateBloomFilter = false @@ -40,6 +47,7 @@ class BlockTransactionProcessor( extractor.extract(existingTransaction) transactionListener?.onTransactionReceived(existingTransaction) relay(existingTransaction.header, index, block) + resolveConflicts(fullTransaction) storage.updateTransaction(existingTransaction) updated.add(existingTransaction.header) @@ -61,11 +69,7 @@ class BlockTransactionProcessor( } relay(transaction, index, block) - - conflictsResolver.getTransactionsConflictingWithInBlockTransaction(fullTransaction).forEach { - it.conflictingTxHash = fullTransaction.header.hash - invalidator.invalidate(it) - } + resolveConflicts(fullTransaction) val invalidTransaction = storage.getInvalidTransaction(transaction.hash) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/PendingTransactionProcessor.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/PendingTransactionProcessor.kt index daef3bb0f..13f910243 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/PendingTransactionProcessor.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/PendingTransactionProcessor.kt @@ -8,7 +8,6 @@ import io.horizontalsystems.bitcoincore.core.inTopologicalOrder import io.horizontalsystems.bitcoincore.extensions.toReversedHex import io.horizontalsystems.bitcoincore.managers.BloomFilterManager import io.horizontalsystems.bitcoincore.managers.IIrregularOutputFinder -import io.horizontalsystems.bitcoincore.managers.PublicKeyManager import io.horizontalsystems.bitcoincore.models.Transaction import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.transactions.extractors.TransactionExtractor @@ -19,12 +18,25 @@ class PendingTransactionProcessor( private val publicKeyManager: IPublicKeyManager, private val irregularOutputFinder: IIrregularOutputFinder, private val dataListener: IBlockchainDataListener, - private val conflictsResolver: TransactionConflictsResolver) { + private val conflictsResolver: TransactionConflictsResolver +) { private val notMineTransactions = HashSet() var transactionListener: WatchedTransactionManager? = null + private fun resolveConflicts(transaction: FullTransaction, updated: MutableList) { + val conflictingTransactions = conflictsResolver.getTransactionsConflictingWithPendingTransaction(transaction) + + for (conflictingTransaction in conflictingTransactions) { + for (descendantTransaction in storage.getDescendantTransactions(conflictingTransaction.hash)) { + descendantTransaction.conflictingTxHash = transaction.header.hash + storage.updateTransaction(descendantTransaction) + updated.add(descendantTransaction) + } + } + } + fun processCreated(transaction: FullTransaction) { if (storage.getTransaction(transaction.header.hash) != null) { throw TransactionCreator.TransactionAlreadyExists("hash = ${transaction.header.hash.toReversedHex()}") @@ -67,6 +79,8 @@ class PendingTransactionProcessor( val existingTransaction = storage.getTransaction(transaction.header.hash) if (existingTransaction != null) { + resolveConflicts(transaction, updated) + if (existingTransaction.status == Transaction.Status.RELAYED) { // if comes again from memPool we don't need to update it continue @@ -97,20 +111,9 @@ class PendingTransactionProcessor( continue } - - - val conflictingTransactions = conflictsResolver.getTransactionsConflictingWithPendingTransaction(transaction) - if (conflictingTransactions.isNotEmpty()) { - // Ignore current transaction and mark former transactions as conflicting with current transaction - conflictingTransactions.forEach { tx -> - tx.conflictingTxHash = transaction.header.hash - storage.updateTransaction(tx) - updated.add(tx) - } - } else { - storage.addTransaction(transaction) - inserted.add(transaction.header) - } + resolveConflicts(transaction, updated) + storage.addTransaction(transaction) + inserted.add(transaction.header) if (!skipCheckBloomFilter) { val checkDoubleSpend = !transaction.header.isOutgoing diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionConflictsResolver.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionConflictsResolver.kt index ae66503d7..47b3279a0 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionConflictsResolver.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/transactions/TransactionConflictsResolver.kt @@ -19,7 +19,27 @@ class TransactionConflictsResolver(private val storage: IStorage) { // If any of conflicting transactions is already in a block, then current transaction is invalid and non of them is conflicting with it. if (conflictingTransactions.any { it.blockHash != null }) return listOf() - return conflictingTransactions + val conflictingFullTransactions = storage.getFullTransactions(conflictingTransactions) + return conflictingFullTransactions + // If an existing transaction has a conflicting input with higher sequence, + // then mempool transaction most probably has been received before + // and the existing transaction is a replacement transaction that is not relayed in mempool yet. + // Other cases are theoretically possible, but highly unlikely + .filter { !existingHasHigherSequence(mempoolTransaction = transaction, existingTransaction = it) } + .map { it.header } + } + + private fun existingHasHigherSequence(mempoolTransaction: FullTransaction, existingTransaction: FullTransaction): Boolean { + existingTransaction.inputs.forEach { existingInput -> + val mempoolInput = mempoolTransaction.inputs.firstOrNull { mempoolInput -> + mempoolInput.previousOutputTxHash.contentEquals(existingInput.previousOutputTxHash) + && mempoolInput.previousOutputIndex == existingInput.previousOutputIndex + } + if (mempoolInput != null && mempoolInput.sequence < existingInput.sequence) + return true + } + + return false } fun getIncomingPendingTransactionsConflictingWith(transaction: FullTransaction): List { @@ -28,13 +48,13 @@ class TransactionConflictsResolver(private val storage: IStorage) { if (incomingPendingTxHashes.isEmpty()) return listOf() val conflictingTransactionHashes = storage - .getTransactionInputs(incomingPendingTxHashes) - .filter { input -> - transaction.inputs.any { it.previousOutputIndex == input.previousOutputIndex && it.previousOutputTxHash.contentEquals(input.previousOutputTxHash) } - } - .map { - it.transactionHash - } + .getTransactionInputs(incomingPendingTxHashes) + .filter { input -> + transaction.inputs.any { it.previousOutputIndex == input.previousOutputIndex && it.previousOutputTxHash.contentEquals(input.previousOutputTxHash) } + } + .map { + it.transactionHash + } if (conflictingTransactionHashes.isEmpty()) return listOf() From 697ad5306a793fa44668142ba4025ee4c670daf9 Mon Sep 17 00:00:00 2001 From: chyngyz Date: Mon, 4 Mar 2024 12:31:07 +0600 Subject: [PATCH 3/6] Replacement Transaction: - Add timelocked inputs sequence incrementation - Improvements and refactoring --- .../bitcoincore/AbstractKit.kt | 17 ++- .../bitcoincore/BitcoinCore.kt | 11 +- .../bitcoincore/core/PluginManager.kt | 12 +- .../bitcoincore/rbf/ReplacementTransaction.kt | 5 + .../rbf/ReplacementTransactionBuilder.kt | 105 ++++++++++++++---- .../bitcoincore/rbf/ReplacementType.kt | 3 +- .../horizontalsystems/hodler/HodlerPlugin.kt | 16 ++- 7 files changed, 135 insertions(+), 34 deletions(-) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt index 530e987d5..248519fe4 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/AbstractKit.kt @@ -10,6 +10,7 @@ import io.horizontalsystems.bitcoincore.models.TransactionInfo import io.horizontalsystems.bitcoincore.models.UsedAddress import io.horizontalsystems.bitcoincore.network.Network import io.horizontalsystems.bitcoincore.rbf.ReplacementTransaction +import io.horizontalsystems.bitcoincore.rbf.ReplacementTransactionInfo import io.horizontalsystems.bitcoincore.rbf.ReplacementType import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput @@ -196,16 +197,24 @@ abstract class AbstractKit { } fun cancelTransaction(transactionHash: String, minFee: Long): ReplacementTransaction { - val changeAddress = bitcoinCore.changeAddress() - return bitcoinCore.replacementTransaction(transactionHash, minFee, ReplacementType.Cancel(changeAddress)) + val publicKey = bitcoinCore.receivePublicKey() + val address = bitcoinCore.address(publicKey) + return bitcoinCore.replacementTransaction(transactionHash, minFee, ReplacementType.Cancel(address, publicKey)) } fun send(replacementTransaction: ReplacementTransaction): FullTransaction { return bitcoinCore.send(replacementTransaction) } - fun replacementTransactionInfo(transactionHash: String): Pair? { - return bitcoinCore.replacementTransactionInfo(transactionHash) + fun speedUpTransactionInfo(transactionHash: String): ReplacementTransactionInfo? { + return bitcoinCore.replacementTransactionInfo(transactionHash, ReplacementType.SpeedUp) + } + + fun cancelTransactionInfo(transactionHash: String): ReplacementTransactionInfo? { + val receivePublicKey = bitcoinCore.receivePublicKey() + val address = bitcoinCore.address(receivePublicKey) + val type = ReplacementType.Cancel(address, receivePublicKey) + return bitcoinCore.replacementTransactionInfo(transactionHash, type) } } diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt index c5c0c2904..baaf13dc0 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/BitcoinCore.kt @@ -39,6 +39,7 @@ import io.horizontalsystems.bitcoincore.network.peer.PeerManager import io.horizontalsystems.bitcoincore.network.peer.PeerTaskHandlerChain import io.horizontalsystems.bitcoincore.rbf.ReplacementTransaction import io.horizontalsystems.bitcoincore.rbf.ReplacementTransactionBuilder +import io.horizontalsystems.bitcoincore.rbf.ReplacementTransactionInfo import io.horizontalsystems.bitcoincore.rbf.ReplacementType import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput @@ -268,8 +269,8 @@ class BitcoinCore( return addressConverter.convert(publicKeyManager.receivePublicKey(), purpose.scriptType).stringValue } - fun changeAddress(): Address { - return addressConverter.convert(publicKeyManager.changePublicKey(), purpose.scriptType) + fun address(publicKey: PublicKey): Address { + return addressConverter.convert(publicKey, purpose.scriptType) } fun usedAddresses(change: Boolean): List { @@ -463,10 +464,8 @@ class BitcoinCore( return transactionCreator.create(replacementTransaction.mutableTransaction) } - fun replacementTransactionInfo(transactionHash: String): Pair? { - val (fullInfo, feeRange) = this.replacementTransactionBuilder?.replacementInfo(transactionHash) ?: return null - - return Pair(dataProvider.transactionInfo(fullInfo), feeRange) + fun replacementTransactionInfo(transactionHash: String, type: ReplacementType): ReplacementTransactionInfo? { + return replacementTransactionBuilder?.replacementInfo(transactionHash, type) } sealed class KitState { diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/PluginManager.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/PluginManager.kt index fa6c328f9..d7e4d4f7a 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/PluginManager.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/core/PluginManager.kt @@ -2,9 +2,9 @@ package io.horizontalsystems.bitcoincore.core import io.horizontalsystems.bitcoincore.managers.IRestoreKeyConverter import io.horizontalsystems.bitcoincore.models.Address -import io.horizontalsystems.bitcoincore.models.PublicKey import io.horizontalsystems.bitcoincore.models.TransactionOutput import io.horizontalsystems.bitcoincore.storage.FullTransaction +import io.horizontalsystems.bitcoincore.storage.InputWithPreviousOutput import io.horizontalsystems.bitcoincore.storage.UnspentOutput import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction import io.horizontalsystems.bitcoincore.transactions.scripts.Script @@ -79,9 +79,16 @@ class PluginManager { plugin.validateAddress(address) } } + + fun incrementedSequence(inputWithPreviousOutput: InputWithPreviousOutput): Long { + val plugin = inputWithPreviousOutput.previousOutput?.pluginId?.let { plugins[it] } + val sequence = inputWithPreviousOutput.input.sequence + + return plugin?.incrementSequence(sequence) ?: (sequence + 1) + } } -interface IPlugin: IRestoreKeyConverter { +interface IPlugin : IRestoreKeyConverter { val id: Byte fun processOutputs(mutableTransaction: MutableTransaction, pluginData: IPluginData, skipChecking: Boolean) @@ -90,6 +97,7 @@ interface IPlugin: IRestoreKeyConverter { fun getInputSequence(output: TransactionOutput): Long fun parsePluginData(output: TransactionOutput, txTimestamp: Long): IPluginOutputData fun validateAddress(address: Address) + fun incrementSequence(sequence: Long): Long } interface IPluginData diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt index 0fa5b30f5..963a5fc27 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransaction.kt @@ -8,3 +8,8 @@ data class ReplacementTransaction( val info: TransactionInfo, val replacedTransactionHashes: List ) + +data class ReplacementTransactionInfo( + val originalTransactionSize: Long, + val feeRange: LongRange +) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt index 29b182edd..b4808447d 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt @@ -21,6 +21,7 @@ import io.horizontalsystems.bitcoincore.transactions.TransactionSizeCalculator import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction import io.horizontalsystems.bitcoincore.transactions.extractors.TransactionMetadataExtractor import io.horizontalsystems.bitcoincore.utils.ShuffleSorter +import kotlin.math.min class ReplacementTransactionBuilder( private val storage: IStorage, @@ -70,8 +71,14 @@ class ReplacementTransactionBuilder( return null } - private fun incrementedSequence(input: TransactionInput): Long { - return input.sequence + 1 // TODO: increment locked inputs sequence + private fun incrementedSequence(inputWithPreviousOutput: InputWithPreviousOutput): Long { + val input = inputWithPreviousOutput.input + + if (inputWithPreviousOutput.previousOutput?.pluginId != null) { + return pluginManager.incrementedSequence(inputWithPreviousOutput) + } + + return min(input.sequence + 1, 0xFFFFFFFF) } private fun inputToSign(previousOutput: TransactionOutput, publicKey: PublicKey, sequence: Long): InputToSign { @@ -94,7 +101,7 @@ class ReplacementTransactionBuilder( originalInputs.map { inputWithPreviousOutput -> val previousOutput = inputWithPreviousOutput.previousOutput ?: throw BuildError.InvalidTransaction val publicKey = previousOutput.publicKeyPath?.let { publicKeyManager.getPublicKeyByPath(it) } ?: throw BuildError.InvalidTransaction - mutableTransaction.addInput(inputToSign(previousOutput = previousOutput, publicKey, incrementedSequence(inputWithPreviousOutput.input))) + mutableTransaction.addInput(inputToSign(previousOutput = previousOutput, publicKey, incrementedSequence(inputWithPreviousOutput))) } } @@ -114,12 +121,16 @@ class ReplacementTransactionBuilder( fixedUtxo: List ): MutableTransaction? { // If an output has a pluginId, it most probably has a time-locked value and it shouldn't be altered. - val fixedOutputs = originalFullInfo.outputs.filter { it.publicKeyPath == null || it.pluginId != null } + var fixedOutputs = originalFullInfo.outputs.filter { it.publicKeyPath == null || it.pluginId != null } val myOutputs = originalFullInfo.outputs.filter { it.publicKeyPath != null && it.pluginId == null } val myChangeOutputs = myOutputs.filter { it.changeOutput }.sortedBy { it.value } val myExternalOutputs = myOutputs.filter { !it.changeOutput }.sortedBy { it.value } - val sortedOutputs = myChangeOutputs + myExternalOutputs + var sortedOutputs = myChangeOutputs + myExternalOutputs + if (fixedOutputs.isEmpty() && sortedOutputs.isNotEmpty()) { + fixedOutputs = listOf(sortedOutputs.last()) + sortedOutputs = sortedOutputs.dropLast(1) + } val unusedUtxo = unspentOutputProvider.getConfirmedSpendableUtxo().sortedBy { it.output.value } var optimalReplacement: Triple, /*outputs*/ List, /*fee*/ Long>? = null @@ -173,10 +184,10 @@ class ReplacementTransactionBuilder( private fun cancelReplacement( originalFullInfo: FullTransactionInfo, minFee: Long, - originalFee: Long, originalFeeRate: Int, fixedUtxo: List, - changeAddress: Address + userAddress: Address, + publicKey: PublicKey ): MutableTransaction? { val unusedUtxo = unspentOutputProvider.getConfirmedSpendableUtxo().sortedBy { it.output.value } val originalInputsValue = fixedUtxo.sumOf { it.value } @@ -186,15 +197,21 @@ class ReplacementTransactionBuilder( var utxoCount = 0 val outputs = listOf( TransactionOutput( - value = originalInputsValue - originalFee, + value = originalInputsValue - minFee, index = 0, - script = changeAddress.lockingScript, - type = changeAddress.scriptType, - address = changeAddress.stringValue, - lockingScriptPayload = changeAddress.lockingScriptPayload + script = userAddress.lockingScript, + type = userAddress.scriptType, + address = userAddress.stringValue, + lockingScriptPayload = userAddress.lockingScriptPayload, + publicKey = publicKey ) ) do { + if (originalInputsValue - minFee < dustCalculator.dust(userAddress.scriptType)) { + utxoCount++ + continue + } + val utxo = unusedUtxo.take(utxoCount) replacementTransaction( @@ -239,7 +256,6 @@ class ReplacementTransactionBuilder( minFee: Long, type: ReplacementType ): Triple> { - // TODO: Need to check that this transaction has not been replaced already val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) ?: throw BuildError.InvalidTransaction check(originalFullInfo.block == null) { "Transaction already in block" } @@ -260,12 +276,19 @@ class ReplacementTransactionBuilder( val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } - check(descendantTransactions.all { it.header.conflictingTxHash == null }) { "Already replaced"} + check(descendantTransactions.all { it.header.conflictingTxHash == null }) { "Already replaced" } check(absoluteFee <= minFee) { "Fee too low" } val mutableTransaction = when (type) { ReplacementType.SpeedUp -> speedUpReplacement(originalFullInfo, minFee, originalFeeRate, fixedUtxo) - is ReplacementType.Cancel -> cancelReplacement(originalFullInfo, minFee, originalFee, originalFeeRate, fixedUtxo, type.changeAddress) + is ReplacementType.Cancel -> cancelReplacement( + originalFullInfo, + minFee, + originalFeeRate, + fixedUtxo, + type.address, + type.publicKey + ) } checkNotNull(mutableTransaction) { "Unable to replace" } @@ -289,19 +312,61 @@ class ReplacementTransactionBuilder( ) } - fun replacementInfo(transactionHash: String): Pair? { + fun replacementInfo(transactionHash: String, type: ReplacementType): ReplacementTransactionInfo? { val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) ?: return null check(originalFullInfo.block == null) { "Transaction already in block" } check(originalFullInfo.metadata.type != TransactionType.Incoming) { "Can replace only outgoing transaction" } + val originalFee = originalFullInfo.metadata.fee + checkNotNull(originalFee) { "No fee for original transaction" } + + val fixedUtxo = originalFullInfo.inputs.mapNotNull { it.previousOutput } + check(originalFullInfo.inputs.size == fixedUtxo.size) { "No previous output" } + val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } + + val originalSize: Long + val removableOutputsValue: Long + + when (type) { + ReplacementType.SpeedUp -> { + var fixedOutputs = originalFullInfo.outputs.filter { it.publicKeyPath == null || it.pluginId != null } + val myOutputs = originalFullInfo.outputs.filter { it.publicKeyPath != null && it.pluginId == null } + val myChangeOutputs = myOutputs.filter { it.changeOutput }.sortedBy { it.value } + val myExternalOutputs = myOutputs.filter { !it.changeOutput }.sortedBy { it.value } + + var sortedOutputs = myChangeOutputs + myExternalOutputs + if (fixedOutputs.isEmpty() && sortedOutputs.isNotEmpty()) { + fixedOutputs = listOf(sortedOutputs.last()) + sortedOutputs = sortedOutputs.dropLast(1) + } + + originalSize = sizeCalculator.transactionSize(fixedUtxo, fixedOutputs) + removableOutputsValue = sortedOutputs.sumOf { it.value } + } + + is ReplacementType.Cancel -> { + val dustValue = dustCalculator.dust(type.address.scriptType).toLong() + val fixedOutputs = listOf( + TransactionOutput( + value = dustValue, + index = 0, + script = type.address.lockingScript, + type = type.address.scriptType, + address = type.address.stringValue, + lockingScriptPayload = type.address.lockingScriptPayload + ) + ) + originalSize = sizeCalculator.transactionSize(fixedUtxo, fixedOutputs) + removableOutputsValue = originalFullInfo.outputs.sumOf { it.value } - dustValue + } + } + val confirmedUtxoTotalValue = unspentOutputProvider.getConfirmedSpendableUtxo().sumOf { it.output.value } - val myOutputs = originalFullInfo.outputs.filter { it.publicKeyPath != null && it.pluginId == null } - val myOutputsTotalValue = myOutputs.sumOf { it.value } + val feeRange = LongRange(absoluteFee, originalFee + removableOutputsValue + confirmedUtxoTotalValue) - val feeRange = LongRange(absoluteFee, absoluteFee + myOutputsTotalValue + confirmedUtxoTotalValue) - return Pair(originalFullInfo, feeRange) + return ReplacementTransactionInfo(originalSize, feeRange) } sealed class BuildError : Throwable() { diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt index 198587b21..1e8c4494b 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementType.kt @@ -1,8 +1,9 @@ package io.horizontalsystems.bitcoincore.rbf import io.horizontalsystems.bitcoincore.models.Address +import io.horizontalsystems.bitcoincore.models.PublicKey sealed class ReplacementType { object SpeedUp : ReplacementType() - data class Cancel(val changeAddress: Address) : ReplacementType() + data class Cancel(val address: Address, val publicKey: PublicKey) : ReplacementType() } diff --git a/hodler/src/main/kotlin/io/horizontalsystems/hodler/HodlerPlugin.kt b/hodler/src/main/kotlin/io/horizontalsystems/hodler/HodlerPlugin.kt index 007222c29..176404e86 100644 --- a/hodler/src/main/kotlin/io/horizontalsystems/hodler/HodlerPlugin.kt +++ b/hodler/src/main/kotlin/io/horizontalsystems/hodler/HodlerPlugin.kt @@ -11,9 +11,15 @@ import io.horizontalsystems.bitcoincore.models.TransactionOutput import io.horizontalsystems.bitcoincore.storage.FullTransaction import io.horizontalsystems.bitcoincore.storage.UnspentOutput import io.horizontalsystems.bitcoincore.transactions.builder.MutableTransaction -import io.horizontalsystems.bitcoincore.transactions.scripts.* +import io.horizontalsystems.bitcoincore.transactions.scripts.OP_1 +import io.horizontalsystems.bitcoincore.transactions.scripts.OP_CHECKSEQUENCEVERIFY +import io.horizontalsystems.bitcoincore.transactions.scripts.OP_DROP +import io.horizontalsystems.bitcoincore.transactions.scripts.OpCodes +import io.horizontalsystems.bitcoincore.transactions.scripts.Script +import io.horizontalsystems.bitcoincore.transactions.scripts.ScriptType import io.horizontalsystems.bitcoincore.utils.IAddressConverter import io.horizontalsystems.bitcoincore.utils.Utils +import kotlin.math.min class HodlerPlugin( private val addressConverter: IAddressConverter, @@ -107,6 +113,14 @@ class HodlerPlugin( } } + override fun incrementSequence(sequence: Long): Long { + val maxInc: Long = 0x7f800000 + val currentInc = sequence and maxInc + val newInc = min(currentInc + (1 shl 23), maxInc) + val zeroIncSequence = (0xffffffff - maxInc) and sequence + return zeroIncSequence or newInc + } + private fun redeemScript(lockTimeInterval: LockTimeInterval, pubkeyHash: ByteArray): ByteArray { return OpCodes.push(lockTimeInterval.sequenceNumberAs3BytesLE) + byteArrayOf( OP_CHECKSEQUENCEVERIFY.toByte(), From 26adec57a4b1fceb83ca4de4e29cd8d12cb228c7 Mon Sep 17 00:00:00 2001 From: chyngyz Date: Thu, 7 Mar 2024 16:34:29 +0600 Subject: [PATCH 4/6] Fix creating FullTransaction from Transaction --- .../horizontalsystems/bitcoincore/storage/Storage.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt index 8dcbf33a5..de9e789d1 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/Storage.kt @@ -276,15 +276,15 @@ open class Storage(protected open val store: CoreDatabase) : IStorage { override fun getFullTransactions(transactions: List): List { val hashes = transactions.map { it.hash } - val inputsByTransaction = store.input.getTransactionInputs(hashes).groupBy { it.transactionHash } - val outputsByTransaction = store.output.getTransactionsOutputs(hashes).groupBy { it.transactionHash } - val metadataByTransaction = store.transactionMetadata.getTransactionMetadata(hashes).associateBy { it.transactionHash } + val inputsByTransaction = store.input.getTransactionInputs(hashes).groupBy { it.transactionHash.toHexString() } + val outputsByTransaction = store.output.getTransactionsOutputs(hashes).groupBy { it.transactionHash.toHexString() } + val metadataByTransaction = store.transactionMetadata.getTransactionMetadata(hashes).associateBy { it.transactionHash.toHexString() } return transactions.map { transaction -> - val inputs = inputsByTransaction[transaction.hash] ?: listOf() - val outputs = outputsByTransaction[transaction.hash] ?: listOf() + val inputs = inputsByTransaction[transaction.hash.toHexString()] ?: listOf() + val outputs = outputsByTransaction[transaction.hash.toHexString()] ?: listOf() FullTransaction(transaction, inputs, outputs, false).apply { - metadata = metadataByTransaction[transaction.hash] ?: TransactionMetadata(transaction.hash) + metadata = metadataByTransaction[transaction.hash.toHexString()] ?: TransactionMetadata(transaction.hash) } } } From ac5a6c18c3e338a6d04f11b82d46033e902433dd Mon Sep 17 00:00:00 2001 From: chyngyz Date: Thu, 7 Mar 2024 16:51:01 +0600 Subject: [PATCH 5/6] Use specific Exceptions in ReplacementTransactionBuilder --- .../rbf/ReplacementTransactionBuilder.kt | 35 ++++++++++--------- .../storage/TransactionInputDao.kt | 12 ------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt index b4808447d..3663e4782 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt @@ -99,8 +99,10 @@ class ReplacementTransactionBuilder( pluginManager.processInputs(mutableTransaction) originalInputs.map { inputWithPreviousOutput -> - val previousOutput = inputWithPreviousOutput.previousOutput ?: throw BuildError.InvalidTransaction - val publicKey = previousOutput.publicKeyPath?.let { publicKeyManager.getPublicKeyByPath(it) } ?: throw BuildError.InvalidTransaction + val previousOutput = + inputWithPreviousOutput.previousOutput ?: throw BuildError.InvalidTransaction("No previous output of original transaction") + val publicKey = previousOutput.publicKeyPath?.let { publicKeyManager.getPublicKeyByPath(it) } + ?: throw BuildError.InvalidTransaction("No public key of original transaction") mutableTransaction.addInput(inputToSign(previousOutput = previousOutput, publicKey, incrementedSequence(inputWithPreviousOutput))) } } @@ -256,19 +258,20 @@ class ReplacementTransactionBuilder( minFee: Long, type: ReplacementType ): Triple> { - val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) ?: throw BuildError.InvalidTransaction - check(originalFullInfo.block == null) { "Transaction already in block" } + val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) + ?: throw BuildError.InvalidTransaction("No FullTransactionInfo") + check(originalFullInfo.block == null) { throw BuildError.InvalidTransaction("Transaction already in block") } val originalFee = originalFullInfo.metadata.fee - checkNotNull(originalFee) { "No fee for original transaction" } + checkNotNull(originalFee) { throw BuildError.InvalidTransaction("No fee for original transaction") } - check(originalFullInfo.metadata.type != TransactionType.Incoming) { "Can replace only outgoing transaction" } + check(originalFullInfo.metadata.type != TransactionType.Incoming) { throw BuildError.InvalidTransaction("Can replace only outgoing transaction") } val fixedUtxo = originalFullInfo.inputs.mapNotNull { it.previousOutput } - check(originalFullInfo.inputs.size == fixedUtxo.size) { "No previous output" } + check(originalFullInfo.inputs.size == fixedUtxo.size) { throw BuildError.NoPreviousOutput } - check(originalFullInfo.inputs.any { it.input.rbfEnabled }) { "Rbf not enabled" } + check(originalFullInfo.inputs.any { it.input.rbfEnabled }) { throw BuildError.RbfNotEnabled } val originalSize = sizeCalculator.transactionSize(previousOutputs = fixedUtxo, outputs = originalFullInfo.outputs) @@ -276,8 +279,8 @@ class ReplacementTransactionBuilder( val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } - check(descendantTransactions.all { it.header.conflictingTxHash == null }) { "Already replaced" } - check(absoluteFee <= minFee) { "Fee too low" } + check(descendantTransactions.all { it.header.conflictingTxHash == null }) { throw BuildError.InvalidTransaction("Already replaced") } + check(absoluteFee <= minFee) { throw BuildError.FeeTooLow } val mutableTransaction = when (type) { ReplacementType.SpeedUp -> speedUpReplacement(originalFullInfo, minFee, originalFeeRate, fixedUtxo) @@ -291,7 +294,7 @@ class ReplacementTransactionBuilder( ) } - checkNotNull(mutableTransaction) { "Unable to replace" } + checkNotNull(mutableTransaction) { throw BuildError.UnableToReplace } val fullTransaction = mutableTransaction.build() metadataExtractor.extract(fullTransaction) @@ -314,14 +317,14 @@ class ReplacementTransactionBuilder( fun replacementInfo(transactionHash: String, type: ReplacementType): ReplacementTransactionInfo? { val originalFullInfo = storage.getFullTransactionInfo(transactionHash.toReversedByteArray()) ?: return null - check(originalFullInfo.block == null) { "Transaction already in block" } - check(originalFullInfo.metadata.type != TransactionType.Incoming) { "Can replace only outgoing transaction" } + check(originalFullInfo.block == null) { throw BuildError.InvalidTransaction("Transaction already in block") } + check(originalFullInfo.metadata.type != TransactionType.Incoming) { throw BuildError.InvalidTransaction("Can replace only outgoing transaction") } val originalFee = originalFullInfo.metadata.fee - checkNotNull(originalFee) { "No fee for original transaction" } + checkNotNull(originalFee) { throw BuildError.InvalidTransaction("No fee for original transaction") } val fixedUtxo = originalFullInfo.inputs.mapNotNull { it.previousOutput } - check(originalFullInfo.inputs.size == fixedUtxo.size) { "No previous output" } + check(originalFullInfo.inputs.size == fixedUtxo.size) { throw BuildError.NoPreviousOutput} val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 } @@ -370,7 +373,7 @@ class ReplacementTransactionBuilder( } sealed class BuildError : Throwable() { - object InvalidTransaction : BuildError() + class InvalidTransaction(override val message: String) : BuildError() object NoPreviousOutput : BuildError() object FeeTooLow : BuildError() object RbfNotEnabled : BuildError() diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt index bd150d088..a58023937 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/storage/TransactionInputDao.kt @@ -27,18 +27,6 @@ interface TransactionInputDao { @Query("select * from TransactionInput where transactionHash IN (:hashes)") fun getTransactionInputs(hashes: List): List -// @Query( -// """ -// SELECT -// inputs.*, -// outputs.* -// FROM TransactionInput as inputs -// LEFT JOIN TransactionOutput AS outputs ON outputs.transactionHash = inputs.previousOutputTxHash AND outputs.`index` = inputs.previousOutputIndex -// WHERE inputs.transactionHash IN(:txHashes) -// """ -// ) -// fun getInputsWithPrevouts(txHashes: List): List - @Query("select * from TransactionOutput where transactionHash=:transactionHash AND `index`=:index limit 1") fun output(transactionHash: ByteArray, index: Long): TransactionOutput? From fa77eaf96cdd987806c76b9e07b1ae008d67e7e1 Mon Sep 17 00:00:00 2001 From: chyngyz Date: Wed, 13 Mar 2024 16:46:02 +0600 Subject: [PATCH 6/6] Fix getting public key by path --- .../rbf/ReplacementTransactionBuilder.kt | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt index 3663e4782..884fd34eb 100644 --- a/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt +++ b/bitcoincore/src/main/kotlin/io/horizontalsystems/bitcoincore/rbf/ReplacementTransactionBuilder.kt @@ -6,6 +6,7 @@ import io.horizontalsystems.bitcoincore.core.IStorage import io.horizontalsystems.bitcoincore.core.PluginManager import io.horizontalsystems.bitcoincore.extensions.toReversedByteArray import io.horizontalsystems.bitcoincore.extensions.toReversedHex +import io.horizontalsystems.bitcoincore.managers.PublicKeyManager import io.horizontalsystems.bitcoincore.managers.UnspentOutputProvider import io.horizontalsystems.bitcoincore.models.Address import io.horizontalsystems.bitcoincore.models.PublicKey @@ -81,10 +82,10 @@ class ReplacementTransactionBuilder( return min(input.sequence + 1, 0xFFFFFFFF) } - private fun inputToSign(previousOutput: TransactionOutput, publicKey: PublicKey, sequence: Long): InputToSign { + private fun inputToSign(previousOutput: TransactionOutput, prevOutputPublicKey: PublicKey, sequence: Long): InputToSign { val transactionInput = TransactionInput(previousOutput.transactionHash, previousOutput.index.toLong(), sequence = sequence) - return InputToSign(transactionInput, previousOutput, publicKey) + return InputToSign(transactionInput, previousOutput, prevOutputPublicKey) } private fun setInputs( @@ -93,7 +94,7 @@ class ReplacementTransactionBuilder( additionalInputs: List ) { additionalInputs.map { utxo -> - mutableTransaction.addInput(inputToSign(previousOutput = utxo.output, publicKey = utxo.publicKey, sequence = 0x0)) + mutableTransaction.addInput(inputToSign(previousOutput = utxo.output, prevOutputPublicKey = utxo.publicKey, sequence = 0x0)) } pluginManager.processInputs(mutableTransaction) @@ -101,9 +102,23 @@ class ReplacementTransactionBuilder( originalInputs.map { inputWithPreviousOutput -> val previousOutput = inputWithPreviousOutput.previousOutput ?: throw BuildError.InvalidTransaction("No previous output of original transaction") - val publicKey = previousOutput.publicKeyPath?.let { publicKeyManager.getPublicKeyByPath(it) } - ?: throw BuildError.InvalidTransaction("No public key of original transaction") - mutableTransaction.addInput(inputToSign(previousOutput = previousOutput, publicKey, incrementedSequence(inputWithPreviousOutput))) + val prevOutputPublicKey = previousOutput.publicKeyPath?.let { path -> + val parts = path.split("/").map { it.toInt() } + if (parts.size != 3) throw PublicKeyManager.Error.InvalidPath + val account = parts[0] + val change = if (parts[1] == 0) 1 else 0 // change was incorrectly set in PublicKey + val index = parts[2] + val fixedPath = "$account/$change/$index" + + publicKeyManager.getPublicKeyByPath(fixedPath) + } ?: throw BuildError.InvalidTransaction("No public key of original transaction") + mutableTransaction.addInput( + inputToSign( + previousOutput = previousOutput, + prevOutputPublicKey = prevOutputPublicKey, + sequence = incrementedSequence(inputWithPreviousOutput) + ) + ) } } @@ -324,7 +339,7 @@ class ReplacementTransactionBuilder( checkNotNull(originalFee) { throw BuildError.InvalidTransaction("No fee for original transaction") } val fixedUtxo = originalFullInfo.inputs.mapNotNull { it.previousOutput } - check(originalFullInfo.inputs.size == fixedUtxo.size) { throw BuildError.NoPreviousOutput} + check(originalFullInfo.inputs.size == fixedUtxo.size) { throw BuildError.NoPreviousOutput } val descendantTransactions = storage.getDescendantTransactionsFullInfo(transactionHash.toReversedByteArray()) val absoluteFee = descendantTransactions.sumOf { it.metadata.fee ?: 0 }