Skip to content

Commit

Permalink
Use different user keys for the common and refund paths
Browse files Browse the repository at this point in the history
This allows us to easily rotate swap-in addresses and generate a single generic taproot descriptor (for bitcoin core 26 and newer) that can be used to recover
swap-in funds once the refund delay has passed, assuming that:
- user and server keys are static
- user refund keys follow BIP derivation
  • Loading branch information
sstone committed Nov 22, 2023
1 parent 84ca143 commit 8e2388b
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 50 deletions.
92 changes: 58 additions & 34 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,21 @@ interface KeyManager {
val refundDelay: Int = DefaultSwapInParams.RefundDelay
) {
private val userExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInUserKeyPath(chain))
private val userRefundExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInUserRefundKeyPath(chain))
private val swapExtendedPublicKey = DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain)))
private val xpub = DeterministicWallet.encode(swapExtendedPublicKey, DeterministicWallet.tpub)

val userPrivateKey: PrivateKey = userExtendedPrivateKey.privateKey
val userPublicKey: PublicKey = userPrivateKey.publicKey()

val userRefundPrivateKey: PrivateKey = userRefundExtendedPrivateKey.privateKey
val userRefundPublicKey: PublicKey = userPrivateKey.publicKey()

private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain))
fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey

val swapInProtocol = SwapInProtocol(userPublicKey, remoteServerPublicKey, refundDelay)
val swapInProtocolMusig2 = SwapInProtocolMusig2(userPublicKey, remoteServerPublicKey, refundDelay)
val swapInProtocolMusig2 = SwapInProtocolMusig2(userPublicKey, remoteServerPublicKey, userRefundPublicKey, refundDelay)

/**
* The output script descriptor matching our swap-in addresses.
Expand Down Expand Up @@ -211,6 +215,8 @@ interface KeyManager {

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

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

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

fun encodedSwapInUserKeyPath(chain: NodeParams.Chain) = when (chain) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,13 @@ object Deserialization {
sequence = readNumber().toUInt(),
swapInParams = TxAddInputTlv.SwapInParams.read(this),
)
0x03 -> InteractiveTxInput.LocalMusig2SwapIn(
serialId = readNumber(),
previousTx = readTransaction(),
previousTxOutput = readNumber(),
sequence = readNumber().toUInt(),
swapInParams = TxAddInputTlv.SwapInParamsMusig2.read(this),
)
else -> error("unknown discriminator $discriminator for class ${InteractiveTxInput.Local::class}")
}

Expand All @@ -251,6 +258,13 @@ object Deserialization {
sequence = readNumber().toUInt(),
swapInParams = TxAddInputTlv.SwapInParams.read(this)
)
0x03 -> InteractiveTxInput.RemoteSwapInMusig2(
serialId = readNumber(),
outPoint = readOutPoint(),
txOut = TxOut.read(readDelimitedByteArray()),
sequence = readNumber().toUInt(),
swapInParams = TxAddInputTlv.SwapInParamsMusig2.read(this)
)
else -> error("unknown discriminator $discriminator for class ${InteractiveTxInput.Remote::class}")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ object Serialization {
writeNumber(sequence.toLong())
swapInParams.write(this@writeLocalInteractiveTxInput)
}
is InteractiveTxInput.LocalMusig2SwapIn -> i.run {
write(0x03)
writeNumber(serialId)
writeBtcObject(previousTx)
writeNumber(previousTxOutput)
writeNumber(sequence.toLong())
swapInParams.write(this@writeLocalInteractiveTxInput)
}
}

private fun Output.writeRemoteInteractiveTxInput(i: InteractiveTxInput.Remote) = when (i) {
Expand All @@ -295,6 +303,14 @@ object Serialization {
writeNumber(sequence.toLong())
swapInParams.write(this@writeRemoteInteractiveTxInput)
}
is InteractiveTxInput.RemoteSwapInMusig2 -> i.run {
write(0x03)
writeNumber(serialId)
writeBtcObject(outPoint)
writeBtcObject(txOut)
writeNumber(sequence.toLong())
swapInParams.write(this@writeRemoteInteractiveTxInput)
}
}

private fun Output.writeSharedInteractiveTxOutput(o: InteractiveTxOutput.Shared) = o.run {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import fr.acinq.bitcoin.musig2.Musig2
import fr.acinq.bitcoin.musig2.PublicNonce
import fr.acinq.bitcoin.musig2.SecretNonce
import fr.acinq.bitcoin.musig2.SessionCtx
import fr.acinq.lightning.Lightning
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.wire.TxAddInputTlv
import org.kodein.log.newLogger

/**
* legacy swap-in protocol, that uses p2wsh and a single "user + server OR user + delay" script
*/
class SwapInProtocol(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val refundDelay: Int) {

constructor(swapInParams: TxAddInputTlv.SwapInParams) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.refundDelay)
constructor(swapInParams: TxAddInputTlv.SwapInParams) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.refundDelay)

// 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>)))
Expand Down Expand Up @@ -49,18 +50,31 @@ class SwapInProtocol(val userPublicKey: PublicKey, val serverPublicKey: PublicKe
}
}

class SwapInProtocolMusig2(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val refundDelay: Int) {
constructor(swapInParams: TxAddInputTlv.SwapInParams) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.refundDelay)
/**
* new swap-in protocol based on musig2 and taproot: (user key + server key) OR (user refund key + delay)
* for the common case, we use the musig2 aggregate of the user and server keys, spent through the key-spend path
* for the refund case, we use the refund script, spent through the script-spend path
* we use a different user key for the refund case: this allows us to generate generic descriptor for all swap-in addresses
* (see the descriptor() method below)
*/
class SwapInProtocolMusig2(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val userRefundKey: PublicKey, val refundDelay: Int) {
constructor(swapInParams: TxAddInputTlv.SwapInParamsMusig2) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.userRefundKey, swapInParams.refundDelay)

// 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)
// it does not depend upon the user's or server's key, just the user's refund key and the refund delay
val redeemScript = listOf(OP_PUSHDATA(userRefundKey.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)

// the internal pubkey is the musig2 aggregation of the user's and server's public keys: it does not depend upon the user's refund's key
private val internalPubKey = Musig2.keyAgg(listOf(userPublicKey, serverPublicKey)).Q.xOnly()

// it is tweaked with the script's merkle root to get the pubkey that will be exposed
private val commonPubKeyAndParity = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))
val commonPubKey = commonPubKeyAndParity.first
private val parity = commonPubKeyAndParity.second
val pubkeyScript: List<ScriptElt> = Script.pay2tr(commonPubKey)

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

Expand Down Expand Up @@ -111,4 +125,22 @@ class SwapInProtocolMusig2(val userPublicKey: PublicKey, val serverPublicKey: Pu
txHash
)
}

/**
*
* @param chain chain we're on
* @param masterRefundKey master private key for the refund keys. we assume that there is a single level of derivation to compute the refund keys
* @return a taproot descriptor that can be imported in bitcoin core (from version 26 on) to recover user funds once the funding delay has passed
*/
fun descriptor(chain: NodeParams.Chain, masterRefundKey: DeterministicWallet.ExtendedPrivateKey): String {
val prefix = when (chain) {
NodeParams.Chain.Mainnet -> DeterministicWallet.xprv
else -> DeterministicWallet.tprv
}
val xpriv = DeterministicWallet.encode(masterRefundKey, prefix)
val path = masterRefundKey.path.toString().replace('\'', 'h').removePrefix("m")
val desc = "tr(${internalPubKey.value},and_v(v:pk($xpriv$path/*),older($refundDelay)))"
val checksum = Descriptor.checksum(desc)
return "$desc#$checksum"
}
}
27 changes: 23 additions & 4 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/InteractiveTxTlv.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,41 @@ sealed class TxAddInputTlv : Tlv {
}

/** When adding a swap-in input to an interactive-tx, the user needs to provide the corresponding script parameters. */
data class SwapInParams(val userKey: PublicKey, val serverKey: PublicKey, val refundDelay: Int, val version: Int) : TxAddInputTlv() {
data class SwapInParams(val userKey: PublicKey, val serverKey: PublicKey, val refundDelay: Int) : TxAddInputTlv() {
override val tag: Long get() = SwapInParams.tag
override fun write(out: Output) {
LightningCodecs.writeBytes(userKey.value, out)
LightningCodecs.writeBytes(serverKey.value, out)
LightningCodecs.writeU32(refundDelay, out)
LightningCodecs.writeU32(version, out)
}

companion object : TlvValueReader<SwapInParams> {
const val tag: Long = 1107
override fun read(input: Input): SwapInParams = SwapInParams(
PublicKey(LightningCodecs.bytes(input, 33)),
PublicKey(LightningCodecs.bytes(input, 33)),
LightningCodecs.u32(input),
if (input.availableBytes >= 4) LightningCodecs.u32(input) else 1
LightningCodecs.u32(input)
)
}
}

/** When adding a swap-in input to an interactive-tx, the user needs to provide the corresponding script parameters. */
data class SwapInParamsMusig2(val userKey: PublicKey, val serverKey: PublicKey, val userRefundKey: PublicKey, val refundDelay: Int) : TxAddInputTlv() {
override val tag: Long get() = SwapInParamsMusig2.tag
override fun write(out: Output) {
LightningCodecs.writeBytes(userKey.value, out)
LightningCodecs.writeBytes(serverKey.value, out)
LightningCodecs.writeBytes(userRefundKey.value, out)
LightningCodecs.writeU32(refundDelay, out)
}

companion object : TlvValueReader<SwapInParamsMusig2> {
const val tag: Long = 1109
override fun read(input: Input): SwapInParamsMusig2 = SwapInParamsMusig2(
PublicKey(LightningCodecs.bytes(input, 33)),
PublicKey(LightningCodecs.bytes(input, 33)),
PublicKey(LightningCodecs.bytes(input, 33)),
LightningCodecs.u32(input)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ data class TxAddInput(
override val type: Long get() = TxAddInput.type
val sharedInput: OutPoint? = tlvs.get<TxAddInputTlv.SharedInputTxId>()?.let { OutPoint(it.txId.reversed(), previousTxOutput) }
val swapInParams = tlvs.get<TxAddInputTlv.SwapInParams>()
val swapInParamsMusig2 = tlvs.get<TxAddInputTlv.SwapInParamsMusig2>()

override fun write(out: Output) {
LightningCodecs.writeBytes(channelId.toByteArray(), out)
Expand All @@ -355,6 +356,7 @@ data class TxAddInput(
val readers = mapOf(
TxAddInputTlv.SharedInputTxId.tag to TxAddInputTlv.SharedInputTxId.Companion as TlvValueReader<TxAddInputTlv>,
TxAddInputTlv.SwapInParams.tag to TxAddInputTlv.SwapInParams.Companion as TlvValueReader<TxAddInputTlv>,
TxAddInputTlv.SwapInParamsMusig2.tag to TxAddInputTlv.SwapInParamsMusig2.Companion as TlvValueReader<TxAddInputTlv>,
)

override fun read(input: Input): TxAddInput = TxAddInput(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.Lightning.randomBytes32
import fr.acinq.lightning.Lightning.randomKey
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.Commitments
import fr.acinq.lightning.channel.Helpers.Funding
Expand Down Expand Up @@ -557,7 +558,12 @@ class TransactionsTestsCommon : LightningTestSuite() {
val userPrivateKey = PrivateKey(ByteArray(32) { 1 })
val serverPrivateKey = PrivateKey(ByteArray(32) { 2 })

val swapInProtocolMusig2 = SwapInProtocolMusig2(userPrivateKey.publicKey(), serverPrivateKey.publicKey(), 144)
val mnemonics = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split(" ")
val seed = MnemonicCode.toSeed(mnemonics, "")
val masterPrivateKey = DeterministicWallet.derivePrivateKey(DeterministicWallet.generate(seed), "42'/0'").copy(path = KeyPath.empty)
val userRefundPrivateKey = DeterministicWallet.derivePrivateKey(masterPrivateKey, "0").privateKey
val swapInProtocolMusig2 = SwapInProtocolMusig2(userPrivateKey.publicKey(), serverPrivateKey.publicKey(), userRefundPrivateKey.publicKey(), 144)

val swapInTx = Transaction(
version = 2,
txIn = listOf(),
Expand Down Expand Up @@ -595,7 +601,7 @@ class TransactionsTestsCommon : LightningTestSuite() {
txOut = listOf(TxOut(Satoshi(10000), pay2wpkh(PrivateKey(randomBytes32()).publicKey()))),
lockTime = 0
)
val sig = swapInProtocolMusig2.signSwapInputRefund(tx, 0, swapInTx.txOut, userPrivateKey)
val sig = swapInProtocolMusig2.signSwapInputRefund(tx, 0, swapInTx.txOut, userRefundPrivateKey)
val signedTx = tx.updateWitness(0, swapInProtocolMusig2.witnessRefund(sig))
Transaction.correctlySpends(signedTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
Expand All @@ -621,7 +627,7 @@ class TransactionsTestsCommon : LightningTestSuite() {
// DER-encoded ECDSA signatures usually take up to 72 bytes.
val sig = ByteVector64.fromValidHex("90b658d172a51f1b3f1a2becd30942397f5df97da8cd2c026854607e955ad815ccfd87d366e348acc32aaf15ff45263aebbb7ecc913a0e5999133f447aee828c")
val tx = Transaction(2, listOf(TxIn(OutPoint(ByteVector32.Zeroes, 2), 0)), listOf(TxOut(50_000.sat, pay2wpkh(pubkey))), 0)
val swapInProtocol = SwapInProtocolMusig2(pubkey, pubkey, 144)
val swapInProtocol = SwapInProtocolMusig2(pubkey, pubkey, pubkey, 144)
val witness = swapInProtocol.witness(sig)
val swapInput = TxIn(OutPoint(ByteVector32.Zeroes, 3), ByteVector.empty, 0, witness)
val txWithAdditionalInput = tx.copy(txIn = tx.txIn + listOf(swapInput))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.io.ByteArrayInput
import fr.acinq.bitcoin.io.ByteArrayOutput
import fr.acinq.bitcoin.musig2.PublicNonce
import fr.acinq.bitcoin.musig2.SecretNonce
import fr.acinq.lightning.*
import fr.acinq.lightning.Lightning.randomBytes
import fr.acinq.lightning.Lightning.randomBytes32
Expand Down Expand Up @@ -398,7 +397,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() {
TxAddInput(channelId2, 0, tx2, 2, 0u) to ByteVector("0042 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000 0100 0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000 00000002 00000000"),
TxAddInput(channelId1, 561, tx1, 0, 0xfffffffdu) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000000 fffffffd"),
TxAddInput(channelId1, 561, OutPoint(tx1, 1), 5u) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 00000005 fd0451201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106"),
TxAddInput(channelId1, 561, tx1, 1, 5u, TlvStream(TxAddInputTlv.SwapInParams(swapInUserKey, swapInServerKey, swapInRefundDelay, 1))) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005 fd04534a03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f0000009000000001"),
TxAddInput(channelId1, 561, tx1, 1, 5u, TlvStream(TxAddInputTlv.SwapInParams(swapInUserKey, swapInServerKey, swapInRefundDelay))) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005 fd04534603462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f00000090"),
TxAddOutput(channelId1, 1105, 2047.sat, ByteVector("00149357014afd0ccd265658c9ae81efa995e771f472")) to ByteVector("0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472"),
TxRemoveInput(channelId2, 561) to ByteVector("0044 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000231"),
TxRemoveOutput(channelId1, 1) to ByteVector("0045 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000001"),
Expand Down

0 comments on commit 8e2388b

Please sign in to comment.