diff --git a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumClientExtensions.kt b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumClientExtensions.kt index 923d8610d..1e51088a8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumClientExtensions.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumClientExtensions.kt @@ -55,4 +55,4 @@ suspend fun IElectrumClient.computeSpliceCpfpFeerate(commitments: Commitments, t logger.info { "projectedFeerate=$projectedFeerate projectedFee=$projectedFee" } logger.info { "actualFeerate=$actualFeerate actualFee=$actualFee" } return Pair(actualFeerate, actualFee) -} \ No newline at end of file +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumMiniWallet.kt b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumMiniWallet.kt index 57766eb16..095455ae8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumMiniWallet.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/ElectrumMiniWallet.kt @@ -3,6 +3,8 @@ package fr.acinq.lightning.blockchain.electrum import co.touchlab.kermit.Logger import fr.acinq.bitcoin.* import fr.acinq.lightning.SwapInParams +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.info import fr.acinq.lightning.utils.sat @@ -35,7 +37,8 @@ data class WalletState(val addresses: Map) { data class Utxo(val txId: TxId, val outputIndex: Int, val blockHeight: Long, val previousTx: Transaction, val addressMeta: AddressMeta) { val outPoint = OutPoint(previousTx, outputIndex.toLong()) - val amount = previousTx.txOut[outputIndex].amount + val txOut = previousTx.txOut[outputIndex] + val amount = txOut.amount } data class AddressState(val meta: AddressMeta, val alreadyUsed: Boolean, val utxos: List) @@ -43,13 +46,13 @@ data class WalletState(val addresses: Map) { sealed class AddressMeta { data object Single : AddressMeta() data class Derived(val index: Int) : AddressMeta() - } - val AddressMeta.indexOrNull: Int? - get() = when (this) { - is AddressMeta.Single -> null - is AddressMeta.Derived -> this.index - } + val indexOrNull: Int? + get() = when (this) { + is Single -> null + is Derived -> this.index + } + } /** * The utxos of a wallet may be discriminated against their number of confirmations. Typically, this is used in the @@ -87,6 +90,22 @@ data class WalletState(val addresses: Map) { 0 -> swapInParams.minConfirmations else -> swapInParams.minConfirmations - confirmations(utxo) }.coerceAtLeast(0) + + /** Builds a transaction spending all expired utxos and computes the mining fee. The transaction is fully signed but not published. */ + fun spendExpiredSwapIn(swapInKeys: KeyManager.SwapInOnChainKeys, scriptPubKey: ByteVector, feerate: FeeratePerKw): Pair? { + val utxos = readyForRefund.map { + KeyManager.SwapInOnChainKeys.SwapInUtxo( + txOut = it.txOut, + outPoint = it.outPoint, + addressIndex = it.addressMeta.indexOrNull + ) + } + val tx = swapInKeys.createRecoveryTransaction(utxos, scriptPubKey, feerate) + return tx?.let { + val fee = utxos.map { it.txOut.amount }.sum() - tx.txOut.map { it.amount }.sum() + tx to fee + } + } } companion object { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt index c222880c7..6ffe4847e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt @@ -11,6 +11,7 @@ import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.transactions.SwapInProtocol import fr.acinq.lightning.transactions.SwapInProtocolLegacy import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.sum import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.LightningCodecs @@ -161,49 +162,75 @@ interface KeyManager { return swapInProtocol.signSwapInputUser(fundingTx, index, parentTxOuts, userPrivateKey, privateNonce, userNonce, serverNonce) } + data class SwapInUtxo(val txOut: TxOut, val outPoint: OutPoint, val addressIndex: Int?) + + /** + * Create a recovery transaction that spends swap-in outputs after their refund delay has passed. + * @param utxos a list of swap-in utxos + * @param scriptPubKey pubkey script to send funds to + * @param feerate fee rate for the refund transaction + * @return a signed transaction that spends our swap-in transaction. It cannot be published until `swapInTx` has enough confirmations + */ + fun createRecoveryTransaction(utxos: List, scriptPubKey: ByteVector, feerate: FeeratePerKw): Transaction? { + return if (utxos.isEmpty()) { + null + } else { + val unsignedTx = Transaction( + version = 2, + txIn = utxos.map { TxIn(it.outPoint, sequence = refundDelay.toLong()) }, + txOut = listOf(TxOut(0.sat, scriptPubKey)), + lockTime = 0 + ) + + fun sign(tx: Transaction, inputIndex: Int, utxo: SwapInUtxo): Transaction { + return when (val addressIndex = utxo.addressIndex) { + null -> { + val sig = legacySwapInProtocol.signSwapInputUser(tx, inputIndex, utxo.txOut, userPrivateKey) + tx.updateWitness(inputIndex, legacySwapInProtocol.witnessRefund(sig)) + } + else -> { + val userRefundPrivateKey: PrivateKey = DeterministicWallet.derivePrivateKey(userRefundExtendedPrivateKey, addressIndex.toLong()).privateKey + val swapInProtocol = getSwapInProtocol(addressIndex) + val sig = swapInProtocol.signSwapInputRefund(tx, inputIndex, utxos.map { it.txOut }, userRefundPrivateKey) + tx.updateWitness(inputIndex, swapInProtocol.witnessRefund(sig)) + } + } + } + + val fees = run { + val recoveryTx = utxos.foldIndexed(unsignedTx) { index, tx, utxo -> sign(tx, index, utxo) } + Transactions.weight2fee(feerate, recoveryTx.weight()) + } + val inputAmount = utxos.map { it.txOut.amount }.sum() + val outputAmount = inputAmount - fees + val unsignedTx1 = unsignedTx.copy(txOut = listOf(TxOut(outputAmount, scriptPubKey))) + val signedTx = utxos.foldIndexed(unsignedTx1) { index, tx, utxo -> sign(tx, index, utxo) } + signedTx + } + } + /** * Create a recovery transaction that spends a swap-in transaction after the refund delay has passed * @param swapInTx swap-in transaction * @param address address to send funds to - * @param feeRate fee rate for the refund transaction + * @param feerate fee rate for the refund transaction * @return a signed transaction that spends our swap-in transaction. It cannot be published until `swapInTx` has enough confirmations */ - fun createRecoveryTransaction(swapInTx: Transaction, address: String, feeRate: FeeratePerKw): Transaction? { + fun createRecoveryTransaction(swapInTx: Transaction, address: String, feerate: FeeratePerKw): Transaction? { val swapInProtocols = (0 until 100).map { getSwapInProtocol(it) } val utxos = swapInTx.txOut.filter { it.publicKeyScript.contentEquals(Script.write(legacySwapInProtocol.pubkeyScript)) || swapInProtocols.find { p -> p.serializedPubkeyScript == it.publicKeyScript } != null } return if (utxos.isEmpty()) { null } else { Bitcoin.addressToPublicKeyScript(chain.chainHash, address).right?.let { script -> - val ourOutput = TxOut(utxos.map { it.amount }.sum(), script) - val unsignedTx = Transaction( - version = 2, - txIn = utxos.map { TxIn(OutPoint(swapInTx, swapInTx.txOut.indexOf(it).toLong()), sequence = refundDelay.toLong()) }, - txOut = listOf(ourOutput), - lockTime = 0 - ) - - fun sign(tx: Transaction, index: Int, utxo: TxOut): Transaction { - return if (Script.isPay2wsh(utxo.publicKeyScript.toByteArray())) { - val sig = legacySwapInProtocol.signSwapInputUser(tx, index, utxo, userPrivateKey) - tx.updateWitness(index, legacySwapInProtocol.witnessRefund(sig)) - } else { - val i = swapInProtocols.indexOfFirst { it.serializedPubkeyScript == utxo.publicKeyScript } - val userRefundPrivateKey: PrivateKey = DeterministicWallet.derivePrivateKey(userRefundExtendedPrivateKey, i.toLong()).privateKey - val swapInProtocol = swapInProtocols[i] - val sig = swapInProtocol.signSwapInputRefund(tx, index, utxos, userRefundPrivateKey) - tx.updateWitness(index, swapInProtocol.witnessRefund(sig)) - } - } - - val fees = run { - val recoveryTx = utxos.foldIndexed(unsignedTx) { index, tx, utxo -> sign(tx, index, utxo) } - Transactions.weight2fee(feeRate, recoveryTx.weight()) + val swapInUtxos = utxos.map { txOut -> + SwapInUtxo( + txOut = txOut, + outPoint = OutPoint(swapInTx, swapInTx.txOut.indexOf(txOut).toLong()), + addressIndex = if (Script.isPay2wsh(txOut.publicKeyScript.toByteArray())) null else swapInProtocols.indexOfFirst { it.serializedPubkeyScript == txOut.publicKeyScript } + ) } - val unsignedTx1 = unsignedTx.copy(txOut = listOf(ourOutput.copy(amount = ourOutput.amount - fees))) - val recoveryTx = utxos.foldIndexed(unsignedTx1) { index, tx, utxo -> sign(tx, index, utxo) } - // this tx is signed but cannot be published until swapInTx has `refundDelay` confirmations - recoveryTx + createRecoveryTransaction(swapInUtxos, ByteVector(Script.write(script)), feerate) } } }