Skip to content

Commit

Permalink
Add a helper method to spend expired swap-in utxos (#651)
Browse files Browse the repository at this point in the history
The transaction is fully signed but not published, and returned along with mining fees. It is up to the caller to call `electrum.broadcastTransaction(tx)`.
  • Loading branch information
pm47 authored Jun 3, 2024
1 parent 948fca8 commit 7e8482b
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,21 +37,22 @@ data class WalletState(val addresses: Map<String, AddressState>) {

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<Utxo>)

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
Expand Down Expand Up @@ -87,6 +90,22 @@ data class WalletState(val addresses: Map<String, AddressState>) {
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<Transaction, Satoshi>? {
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 {
Expand Down
87 changes: 57 additions & 30 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SwapInUtxo>, 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)
}
}
}
Expand Down

0 comments on commit 7e8482b

Please sign in to comment.