Skip to content

Commit

Permalink
[WIP] Rework swap handling
Browse files Browse the repository at this point in the history
  • Loading branch information
sstone committed Oct 30, 2023
1 parent 336ee57 commit f685ccb
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 160 deletions.
158 changes: 38 additions & 120 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ interface KeyManager {

val swapInOnChainWallet: SwapInOnChainKeys

val swapInOnChainWalletV2: SwapInOnChainKeysV2

/**
* Keys used for the node. They are used to generate the node id, to secure communication with other peers, and
* to sign network-wide public announcements.
Expand Down Expand Up @@ -108,11 +106,15 @@ interface KeyManager {
}

/**
* We use a specific kind of swap-in where users send funds to a 2-of-2 multisig with a timelock refund.
* We use a specific kind of swap-in where users send funds to a shared address with a timelock refund.
* Once confirmed, the swap-in utxos can be spent by one of two paths:
* - with a signature from both [userPublicKey] and [remoteServerPublicKey]
* - with a signature from [userPublicKey] after the [refundDelay]
* The keys used are static across swaps to make recovery easier.
*
* There are 2 implementations for this protocol:
* - a legacy implementation that uses a script(multisig user + server || user + delay), which has a very specific onchain footprint
* - a implementation based on taproot and musig2 that looks like any p2tr transaction onchain
*/
data class SwapInOnChainKeys(
private val chain: NodeParams.Chain,
Expand All @@ -127,124 +129,28 @@ interface KeyManager {
private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain))
fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey

val redeemScript: List<ScriptElt> = Scripts.swapIn2of2(userPublicKey, remoteServerPublicKey, refundDelay)
val pubkeyScript: List<ScriptElt> = Script.pay2wsh(redeemScript)
val address: String = Bitcoin.addressFromPublicKeyScript(chain.chainHash, pubkeyScript).result!!

/**
* The output script descriptor matching our swap-in addresses.
* That descriptor can be imported in bitcoind to recover funds after the refund delay.
*/
val descriptor = run {
// Since child public keys cannot be derived from a master xpub when hardened derivation is used,
// we need to provide the fingerprint of the master xpub and the hardened derivation path.
// This lets wallets that have access to the master xpriv derive the corresponding private and public keys.
val masterFingerprint = ByteVector(Crypto.hash160(DeterministicWallet.publicKey(master).publickeybytes).take(4).toByteArray())
val encodedChildKey = DeterministicWallet.encode(DeterministicWallet.publicKey(userExtendedPrivateKey), testnet = chain != NodeParams.Chain.Mainnet)
val userKey = "[${masterFingerprint.toHex()}/${encodedSwapInUserKeyPath(chain)}]$encodedChildKey"
"wsh(and_v(v:pk($userKey),or_d(pk(${remoteServerPublicKey.toHex()}),older($refundDelay))))"
}

/**
* 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
* @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? {
val utxos = swapInTx.txOut.filter { it.publicKeyScript.contentEquals(Script.write(pubkeyScript)) }
return if (utxos.isEmpty()) {
null
} else {
val pubKeyScript = Bitcoin.addressToPublicKeyScript(chain.chainHash, address).result
pubKeyScript?.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
)
val fees = run {
val recoveryTx = utxos.foldIndexed(unsignedTx) { index, tx, utxo ->
val sig = Transactions.signSwapInputUser(tx, index, utxo, userPrivateKey, remoteServerPublicKey, refundDelay)
tx.updateWitness(index, Scripts.witnessSwapIn2of2Refund(sig, userPublicKey, remoteServerPublicKey, refundDelay))
}
Transactions.weight2fee(feeRate, recoveryTx.weight())
}
val unsignedTx1 = unsignedTx.copy(txOut = listOf(ourOutput.copy(amount = ourOutput.amount - fees)))
val recoveryTx = utxos.foldIndexed(unsignedTx1) { index, tx, utxo ->
val sig = Transactions.signSwapInputUser(tx, index, utxo, userPrivateKey, remoteServerPublicKey, refundDelay)
tx.updateWitness(index, Scripts.witnessSwapIn2of2Refund(sig, userPublicKey, remoteServerPublicKey, refundDelay))
}
// this tx is signed but cannot be published until swapInTx has `refundDelay` confirmations
recoveryTx
}
}
}

companion object {
private fun swapInKeyBasePath(chain: NodeParams.Chain) = when (chain) {
NodeParams.Chain.Regtest, NodeParams.Chain.Testnet -> KeyPath.empty / hardened(51) / hardened(0)
NodeParams.Chain.Mainnet -> KeyPath.empty / hardened(52) / hardened(0)
}

fun swapInUserKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(0)

fun swapInLocalServerKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(1)

fun encodedSwapInUserKeyPath(chain: NodeParams.Chain) = when (chain) {
NodeParams.Chain.Regtest, NodeParams.Chain.Testnet -> "51h/0h/0h"
NodeParams.Chain.Mainnet -> "52h/0h/0h"
}

/** Swap-in servers use a different swap-in key for different users. */
fun perUserPath(remoteNodeId: PublicKey): KeyPath {
// We hash the remote node_id and break it into 2-byte values to get non-hardened path indices.
val h = ByteArrayInput(Crypto.sha256(remoteNodeId.value))
return KeyPath((0 until 16).map { _ -> LightningCodecs.u16(h).toLong() })
}
}
}

/**
* We use a specific kind of swap-in where users send funds to a 2-of-2 multisig with a timelock refund.
* Once confirmed, the swap-in utxos can be spent by one of two paths:
* - with a signature from both [userPublicKey] and [remoteServerPublicKey]
* - with a signature from [userPublicKey] after the [refundDelay]
* The keys used are static across swaps to make recovery easier.
*/
data class SwapInOnChainKeysV2(
private val chain: NodeParams.Chain,
private val master: DeterministicWallet.ExtendedPrivateKey,
val remoteServerPublicKey: PublicKey,
val refundDelay: Int = DefaultSwapInParams.RefundDelay
) {
private val userExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInUserKeyPath(chain))
val userPrivateKey: PrivateKey = userExtendedPrivateKey.privateKey
val userPublicKey: PublicKey = userPrivateKey.publicKey()

private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain))
fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey
// legacy swap-in-potentiam that uses standard multisig 2-of-2
private val legacyRedeemScript: List<ScriptElt> = Scripts.swapIn2of2(userPublicKey, remoteServerPublicKey, refundDelay)
val legacyPubkeyScript: List<ScriptElt> = Script.pay2wsh(legacyRedeemScript)
val legacyAddress: String = Bitcoin.addressFromPublicKeyScript(chain.chainHash, legacyPubkeyScript).result!!

// swap-in-potentiam uses musig2 and taproot
// the redeem script is just the refund script. it is generated from this policy: and_v(v:pk(user),older(refundDelay))
val redeemScript = listOf(OP_PUSHDATA(userPublicKey.xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY)
val scriptTree = ScriptTree.Leaf(ScriptLeaf(0, Script.write(redeemScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT))
val merkleRoot = ScriptTree.hash(scriptTree)

// User and Server exchange public keys and agree on a common aggregated key
val internalPubKey = Musig2.keyAgg(listOf(userPublicKey, remoteServerPublicKey)).Q.xOnly()
val commonPubKeyAndParity = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))

private val redeemScript = listOf(OP_PUSHDATA(userPublicKey.xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY)
private val scriptTree = ScriptTree.Leaf(ScriptLeaf(0, Script.write(redeemScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT))
private val merkleRoot = ScriptTree.hash(scriptTree)
// User and Server exchange public keys and agree on a common musig2 aggregated key, the swapin address is the p2tr address for that musig2 public key tweaked
// with our script tree merkle root which has only one leaf: the refund script
private val internalPubKey = Musig2.keyAgg(listOf(userPublicKey, remoteServerPublicKey)).Q.xOnly()
private val commonPubKeyAndParity = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))
val pubkeyScript: List<ScriptElt> = Script.pay2tr(commonPubKeyAndParity.first)
val address: String = Bitcoin.addressFromPublicKeyScript(chain.chainHash, pubkeyScript).result!!

/**
* The output script descriptor matching our swap-in addresses.
* That descriptor can be imported in bitcoind to recover funds after the refund delay.
*/
val descriptor = run {
val legacyDescriptor = run {
// Since child public keys cannot be derived from a master xpub when hardened derivation is used,
// we need to provide the fingerprint of the master xpub and the hardened derivation path.
// This lets wallets that have access to the master xpriv derive the corresponding private and public keys.
Expand All @@ -254,6 +160,9 @@ interface KeyManager {
"wsh(and_v(v:pk($userKey),or_d(pk(${remoteServerPublicKey.toHex()}),older($refundDelay))))"
}

// TODO: this is wrong. what should we use here ?
val descriptor = legacyDescriptor

/**
* Create a recovery transaction that spends a swap-in transaction after the refund delay has passed
* @param swapInTx swap-in transaction
Expand All @@ -262,7 +171,7 @@ interface KeyManager {
* @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? {
val utxos = swapInTx.txOut.filter { it.publicKeyScript.contentEquals(Script.write(pubkeyScript)) }
val utxos = swapInTx.txOut.filter { it.publicKeyScript.contentEquals(Script.write(legacyPubkeyScript)) || it.publicKeyScript.contentEquals(Script.write(pubkeyScript)) }
return if (utxos.isEmpty()) {
null
} else {
Expand All @@ -275,21 +184,31 @@ interface KeyManager {
txOut = listOf(ourOutput),
lockTime = 0
)

val controlBlock = byteArrayOf((Script.TAPROOT_LEAF_TAPSCRIPT + (if (commonPubKeyAndParity.second) 1 else 0)).toByte()) + internalPubKey.value.toByteArray()
val execData = Script.ExecutionData(annex = null, tapleafHash = merkleRoot)

fun signTx(unsignedTx: Transaction): Transaction = utxos.foldIndexed(unsignedTx) { index, tx, _ ->
val txHash = Transaction.hashForSigningSchnorr(tx, index, utxos, SigHash.SIGHASH_DEFAULT, SigVersion.SIGVERSION_TAPSCRIPT, execData)
val sig = Crypto.signSchnorr(txHash, userPrivateKey, Crypto.SchnorrTweak.NoTweak)
tx.updateWitness(index, ScriptWitness.empty.push(sig).push(redeemScript).push(controlBlock))
fun signInput(tx: Transaction, index: Int, utxo: TxOut): Transaction {
return if (utxo.publicKeyScript.contentEquals(Script.write(legacyPubkeyScript))) {
val sig = Transactions.signSwapInputUser(tx, index, utxo, userPrivateKey, remoteServerPublicKey, refundDelay)
tx.updateWitness(index, Scripts.witnessSwapIn2of2Refund(sig, userPublicKey, remoteServerPublicKey, refundDelay))
} else {
val txHash = Transaction.hashForSigningSchnorr(tx, index, utxos, SigHash.SIGHASH_DEFAULT, SigVersion.SIGVERSION_TAPSCRIPT, execData)
val sig = Crypto.signSchnorr(txHash, userPrivateKey, Crypto.SchnorrTweak.NoTweak)
tx.updateWitness(index, ScriptWitness.empty.push(sig).push(redeemScript).push(controlBlock))
}
}

val fees = run {
val recoveryTx = signTx(unsignedTx)
val recoveryTx = utxos.foldIndexed(unsignedTx) { index, tx, utxo ->
signInput(tx, index, utxo)
}
Transactions.weight2fee(feeRate, recoveryTx.weight())
}
val unsignedTx1 = unsignedTx.copy(txOut = listOf(ourOutput.copy(amount = ourOutput.amount - fees)))
val recoveryTx = signTx(unsignedTx1)
val recoveryTx = utxos.foldIndexed(unsignedTx1) { index, tx, utxo ->
signInput(tx, index, utxo)
}
// this tx is signed but cannot be published until swapInTx has `refundDelay` confirmations
recoveryTx
}
Expand Down Expand Up @@ -319,5 +238,4 @@ interface KeyManager {
}
}
}

}
10 changes: 0 additions & 10 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/LocalKeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,6 @@ data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwa
val remoteSwapInPublicKey = DeterministicWallet.derivePublicKey(xpub, KeyManager.SwapInOnChainKeys.perUserPath(nodeKeys.nodeKey.publicKey)).publicKey
KeyManager.SwapInOnChainKeys(chain, master, remoteSwapInPublicKey)
}
override val swapInOnChainWalletV2: KeyManager.SwapInOnChainKeysV2 = run {
val (prefix, xpub) = DeterministicWallet.ExtendedPublicKey.decode(remoteSwapInExtendedPublicKey)
val expectedPrefix = when (chain) {
Chain.Mainnet -> DeterministicWallet.xpub
else -> DeterministicWallet.tpub
}
require(prefix == expectedPrefix) { "unexpected swap-in xpub prefix $prefix (expected $expectedPrefix)" }
val remoteSwapInPublicKey = DeterministicWallet.derivePublicKey(xpub, KeyManager.SwapInOnChainKeys.perUserPath(nodeKeys.nodeKey.publicKey)).publicKey
KeyManager.SwapInOnChainKeysV2(chain, master, remoteSwapInPublicKey)
}
private val channelKeyBasePath: KeyPath = channelKeyBasePath(chain)

/**
Expand Down
1 change: 1 addition & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class Peer(

val swapInWallet = ElectrumMiniWallet(nodeParams.chainHash, watcher.client, scope, nodeParams.loggerFactory, name = "swap-in")
val swapInAddress: String = nodeParams.keyManager.swapInOnChainWallet.address.also { swapInWallet.addAddress(it) }
val legacySwapInAddress: String = nodeParams.keyManager.swapInOnChainWallet.legacyAddress.also { swapInWallet.addAddress(it) }

private var swapInJob: Job? = null

Expand Down
15 changes: 14 additions & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/transactions/Scripts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fr.acinq.lightning.transactions

import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.ScriptEltMapping.code2elt
import fr.acinq.bitcoin.musig2.Musig2
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.utils.sat
Expand Down Expand Up @@ -31,9 +32,21 @@ object Scripts {
}

/**
* @return the script used for a 2-of-2 swap-in as used in Phoenix.
* @return the script used for a musig2 2-of-2 swap-in as used in Phoenix.
*/
fun swapIn2of2(userKey: PublicKey, serverKey: PublicKey, delayedRefund: Int): List<ScriptElt> {
val redeemScript = listOf(OP_PUSHDATA(userKey.xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(delayedRefund)), OP_CHECKSEQUENCEVERIFY)
val scriptTree = ScriptTree.Leaf(ScriptLeaf(0, Script.write(redeemScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT))
val merkleRoot = ScriptTree.hash(scriptTree)
val internalPubKey = Musig2.keyAgg(listOf(userKey, serverKey)).Q.xOnly()
val (commonPubKey, _) = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))
return Script.pay2tr(commonPubKey)
}

/**
* @return the script used for a legacy 2-of-2 swap-in as used in Phoenix.
*/
fun swapIn2of2Legacy(userKey: PublicKey, serverKey: PublicKey, delayedRefund: Int): List<ScriptElt> {
// This script was generated with https://bitcoin.sipa.be/miniscript/ using the following miniscript policy:
// and(pk(<user_key>),or(99@pk(<server_key>),older(<delayed_refund>)))
// @formatter:off
Expand Down
Loading

0 comments on commit f685ccb

Please sign in to comment.